use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CwdSource {
CliFlag,
Frontmatter,
ProjectRoot,
DocumentParent,
}
impl CwdSource {
pub fn as_str(&self) -> &'static str {
match self {
CwdSource::CliFlag => "cli_flag",
CwdSource::Frontmatter => "frontmatter",
CwdSource::ProjectRoot => "project_root",
CwdSource::DocumentParent => "document_parent",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedCwd {
pub path: PathBuf,
pub source: CwdSource,
}
pub fn resolve(
cli_cwd: Option<&Path>,
frontmatter_cwd: Option<&str>,
document: &Path,
) -> Result<ResolvedCwd> {
let canonical_doc = document
.canonicalize()
.with_context(|| format!("document does not exist: {}", document.display()))?;
let doc_parent = canonical_doc
.parent()
.context("document has no parent directory")?;
if let Some(cli) = cli_cwd {
let path = canonicalize_dir(cli, None, "--cwd flag")?;
return Ok(ResolvedCwd {
path,
source: CwdSource::CliFlag,
});
}
if let Some(fm) = frontmatter_cwd {
let path = canonicalize_dir(Path::new(fm), Some(doc_parent), "agent_doc_cwd frontmatter")?;
return Ok(ResolvedCwd {
path,
source: CwdSource::Frontmatter,
});
}
if let Some(root) = find_project_root(doc_parent) {
return Ok(ResolvedCwd {
path: root,
source: CwdSource::ProjectRoot,
});
}
Ok(ResolvedCwd {
path: doc_parent.to_path_buf(),
source: CwdSource::DocumentParent,
})
}
fn canonicalize_dir(candidate: &Path, base: Option<&Path>, context_label: &str) -> Result<PathBuf> {
let joined: PathBuf = if candidate.is_absolute() {
candidate.to_path_buf()
} else if let Some(base) = base {
base.join(candidate)
} else {
candidate.to_path_buf()
};
let canonical = joined
.canonicalize()
.with_context(|| format!("{}: path does not exist: {}", context_label, joined.display()))?;
if !canonical.is_dir() {
bail!(
"{}: path is not a directory: {}",
context_label,
canonical.display()
);
}
Ok(canonical)
}
fn find_project_root(start: &Path) -> Option<PathBuf> {
let mut current: &Path = start;
loop {
if current.join(".agent-doc").is_dir() {
return Some(current.to_path_buf());
}
current = current.parent()?;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn make_layout() -> (TempDir, PathBuf, PathBuf) {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
fs::create_dir_all(root.join(".agent-doc")).unwrap();
fs::create_dir_all(root.join("docs")).unwrap();
fs::create_dir_all(root.join("subdir")).unwrap();
let doc = root.join("docs").join("plan.md");
fs::write(&doc, "# plan").unwrap();
let canonical_root = root.canonicalize().unwrap();
(tmp, canonical_root, doc)
}
#[test]
fn cli_flag_wins_over_everything() {
let (_tmp, root, doc) = make_layout();
let target = root.join("subdir");
let resolved = resolve(Some(&target), Some(".."), &doc).unwrap();
assert_eq!(resolved.source, CwdSource::CliFlag);
assert_eq!(resolved.path, target.canonicalize().unwrap());
}
#[test]
fn frontmatter_wins_when_no_cli_flag() {
let (_tmp, root, doc) = make_layout();
let resolved = resolve(None, Some(".."), &doc).unwrap();
assert_eq!(resolved.source, CwdSource::Frontmatter);
assert_eq!(resolved.path, root);
}
#[test]
fn frontmatter_absolute_path_is_respected() {
let (_tmp, root, doc) = make_layout();
let target = root.join("subdir");
let resolved = resolve(None, Some(target.to_str().unwrap()), &doc).unwrap();
assert_eq!(resolved.source, CwdSource::Frontmatter);
assert_eq!(resolved.path, target.canonicalize().unwrap());
}
#[test]
fn project_root_wins_when_no_cli_or_frontmatter() {
let (_tmp, root, doc) = make_layout();
let resolved = resolve(None, None, &doc).unwrap();
assert_eq!(resolved.source, CwdSource::ProjectRoot);
assert_eq!(resolved.path, root);
}
#[test]
fn document_parent_fallback_when_no_project_root() {
let tmp = TempDir::new().unwrap();
let doc_dir = tmp.path().join("lonely");
fs::create_dir_all(&doc_dir).unwrap();
let doc = doc_dir.join("plan.md");
fs::write(&doc, "# plan").unwrap();
if find_project_root(&doc_dir.canonicalize().unwrap()).is_some() {
eprintln!("skipping: ancestor has .agent-doc/");
return;
}
let resolved = resolve(None, None, &doc).unwrap();
assert_eq!(resolved.source, CwdSource::DocumentParent);
assert_eq!(resolved.path, doc_dir.canonicalize().unwrap());
}
#[test]
fn cli_flag_nonexistent_path_is_hard_error() {
let (_tmp, _root, doc) = make_layout();
let bogus = PathBuf::from("/definitely/does/not/exist/zzzqx");
let err = resolve(Some(&bogus), None, &doc).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("--cwd flag"), "error message: {}", msg);
}
#[test]
fn cli_flag_file_not_directory_is_hard_error() {
let (_tmp, _root, doc) = make_layout();
let err = resolve(Some(&doc), None, &doc).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("not a directory"), "error message: {}", msg);
}
#[test]
fn frontmatter_nonexistent_does_not_fall_through_to_project_root() {
let (_tmp, _root, doc) = make_layout();
let err = resolve(None, Some("nowhere-zzzqx"), &doc).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("agent_doc_cwd"), "error message: {}", msg);
}
#[test]
fn document_must_exist() {
let tmp = TempDir::new().unwrap();
let doc = tmp.path().join("missing.md");
let err = resolve(None, None, &doc).unwrap_err();
let msg = format!("{:#}", err);
assert!(msg.contains("document does not exist"), "error message: {}", msg);
}
#[test]
fn source_tag_strings_are_stable() {
assert_eq!(CwdSource::CliFlag.as_str(), "cli_flag");
assert_eq!(CwdSource::Frontmatter.as_str(), "frontmatter");
assert_eq!(CwdSource::ProjectRoot.as_str(), "project_root");
assert_eq!(CwdSource::DocumentParent.as_str(), "document_parent");
}
#[test]
fn deeply_nested_document_still_finds_project_root() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".agent-doc")).unwrap();
let nested = root.join("a").join("b").join("c").join("d");
fs::create_dir_all(&nested).unwrap();
let doc = nested.join("plan.md");
fs::write(&doc, "# plan").unwrap();
let resolved = resolve(None, None, &doc).unwrap();
assert_eq!(resolved.source, CwdSource::ProjectRoot);
assert_eq!(resolved.path, root.canonicalize().unwrap());
}
}