use std::ops::Range;
use std::path::{Path, PathBuf};
pub fn research_root() -> PathBuf {
if let Ok(override_path) = std::env::var("ACTIONBOOK_RESEARCH_HOME")
&& !override_path.is_empty()
{
return PathBuf::from(override_path);
}
dirs::home_dir()
.expect("home_dir must be resolvable on supported platforms")
.join(".actionbook")
.join("ascent-research")
}
pub fn legacy_research_root() -> Option<PathBuf> {
if std::env::var("ACTIONBOOK_RESEARCH_HOME").is_ok() {
return None;
}
let legacy = dirs::home_dir()?.join(".actionbook").join("research");
if legacy.exists() { Some(legacy) } else { None }
}
pub fn root_for_slug(slug: &str) -> PathBuf {
let canonical = research_root();
if canonical.join(slug).exists() {
return canonical;
}
if let Some(legacy) = legacy_research_root()
&& legacy.join(slug).exists()
{
return legacy;
}
canonical
}
pub fn research_roots_for_discovery() -> Vec<PathBuf> {
let canonical = research_root();
let mut roots = vec![canonical.clone()];
if let Some(legacy) = legacy_research_root()
&& legacy != canonical
{
roots.push(legacy);
}
roots
}
pub fn session_dir(slug: &str) -> PathBuf {
root_for_slug(slug).join(slug)
}
pub fn session_md(slug: &str) -> PathBuf {
session_dir(slug).join("session.md")
}
pub fn session_jsonl(slug: &str) -> PathBuf {
session_dir(slug).join("session.jsonl")
}
pub fn session_toml(slug: &str) -> PathBuf {
session_dir(slug).join("session.toml")
}
pub fn session_raw_dir(slug: &str) -> PathBuf {
session_dir(slug).join("raw")
}
pub fn session_report_json(slug: &str) -> PathBuf {
session_dir(slug).join("report.json")
}
pub fn session_report_html(slug: &str) -> PathBuf {
session_dir(slug).join("report.html")
}
pub fn session_report_pdf(slug: &str) -> PathBuf {
session_dir(slug).join("report.pdf")
}
pub fn session_wiki_dir(slug: &str) -> PathBuf {
session_dir(slug).join("wiki")
}
pub fn session_schema_md(slug: &str) -> PathBuf {
session_dir(slug).join("SCHEMA.md")
}
pub fn session_wiki_page(slug: &str, page_slug: &str) -> PathBuf {
session_wiki_dir(slug).join(format!("{page_slug}.md"))
}
pub fn active_ptr() -> PathBuf {
research_root().join(".active")
}
pub fn active_lock() -> PathBuf {
research_root().join(".active.lock")
}
pub fn session_jsonl_lock(slug: &str) -> PathBuf {
session_dir(slug).join("session.jsonl.lock")
}
pub fn session_md_lock(slug: &str) -> PathBuf {
session_dir(slug).join("session.md.lock")
}
pub const SOURCES_START_MARKER: &str = "<!-- research:sources-start -->";
pub const SOURCES_END_MARKER: &str = "<!-- research:sources-end -->";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MarkerError {
MissingStart,
MissingEnd,
OutOfOrder,
}
pub fn locate_sources_block(md: &str) -> Result<Range<usize>, MarkerError> {
let start = md
.find(SOURCES_START_MARKER)
.ok_or(MarkerError::MissingStart)?;
let after_start = start + SOURCES_START_MARKER.len();
let end = md[after_start..]
.find(SOURCES_END_MARKER)
.ok_or(MarkerError::MissingEnd)?;
let end_abs = after_start + end;
if end_abs < after_start {
return Err(MarkerError::OutOfOrder);
}
Ok(after_start..end_abs)
}
pub fn path_is_under_root(p: &Path) -> bool {
p.starts_with(research_root())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn markers_are_exact_literals() {
assert_eq!(SOURCES_START_MARKER, "<!-- research:sources-start -->");
assert_eq!(SOURCES_END_MARKER, "<!-- research:sources-end -->");
}
#[test]
fn locate_sources_block_happy() {
let md =
"## Sources\n<!-- research:sources-start -->\nOLD\n<!-- research:sources-end -->\n";
let r = locate_sources_block(md).unwrap();
assert_eq!(&md[r], "\nOLD\n");
}
#[test]
fn locate_sources_block_missing_start() {
let md = "## Sources\nno markers here\n<!-- research:sources-end -->\n";
assert_eq!(locate_sources_block(md), Err(MarkerError::MissingStart));
}
#[test]
fn locate_sources_block_missing_end() {
let md = "## Sources\n<!-- research:sources-start -->\nno end\n";
assert_eq!(locate_sources_block(md), Err(MarkerError::MissingEnd));
}
#[test]
fn layout_paths_are_under_root() {
let root = research_root();
assert!(session_md("foo").starts_with(&root));
assert!(session_jsonl("bar").starts_with(&root));
}
}