use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
use anyhow::{bail, Result};
use serde::Serialize;
use crate::paths::state::StateLayout;
pub const LEGACY_ROADMAP_PATH: &str = "docs/roadmap.md";
pub const DEFAULT_PROJECT_TRUTH_CANDIDATES: &[&str] =
&["AGENTS.md", "MEMORY.md", "README.md", "_MANIFEST.md"];
#[derive(Debug, Clone, Serialize)]
pub struct ManifestResolution {
pub source_order: &'static str,
pub manifest_path: PathBuf,
pub manifest_status: &'static str,
pub entries: Vec<PathBuf>,
pub project_truth_paths: Vec<PathBuf>,
}
pub fn resolve_manifest(
repo_root: &Path,
layout: &StateLayout,
locality_id: &str,
) -> Result<ManifestResolution> {
if let Some(resolution) = resolve_from_overlay_config(repo_root, layout, locality_id)? {
return Ok(resolution);
}
let project_truth_paths = DEFAULT_PROJECT_TRUTH_CANDIDATES
.iter()
.map(|candidate| repo_root.join(candidate))
.filter(|path| path.is_file())
.collect();
Ok(ManifestResolution {
source_order: "default",
manifest_path: layout.repo_overlay_config_path(locality_id)?,
manifest_status: "absent",
entries: Vec::new(),
project_truth_paths,
})
}
pub fn parse_manifest_entries(contents: &str) -> Result<Vec<PathBuf>> {
let mut entries = Vec::new();
let mut seen = HashSet::new();
for raw_line in contents.lines() {
let Some(raw_entry) = extract_manifest_entry(raw_line) else {
continue;
};
let entry = validate_manifest_entry(raw_entry)?;
let key = entry.display().to_string();
if !seen.insert(key.clone()) {
bail!("manifest contains a duplicate source entry `{key}`");
}
entries.push(entry);
}
Ok(entries)
}
pub fn legacy_roadmap_exclusion_warning(
repo_root: &Path,
layout: &StateLayout,
locality_id: &str,
) -> Result<Option<String>> {
let resolution = resolve_manifest(repo_root, layout, locality_id)?;
Ok(legacy_roadmap_exclusion_warning_from_resolution(
repo_root,
&resolution,
))
}
pub fn legacy_roadmap_exclusion_warning_from_resolution(
repo_root: &Path,
resolution: &ManifestResolution,
) -> Option<String> {
let legacy_path = repo_root.join(LEGACY_ROADMAP_PATH);
if !legacy_path.is_file()
|| resolution
.project_truth_paths
.iter()
.any(|path| path == &legacy_path)
{
return None;
}
let source_order = match resolution.source_order {
"config" => format!(
"config.toml source order at {}",
resolution.manifest_path.display()
),
_ => "default source order".to_owned(),
};
let manifest_hint = match resolution.source_order {
"config" => format!(
"add `{LEGACY_ROADMAP_PATH}` to {} explicitly",
resolution.manifest_path.display()
),
_ => format!(
"create {} and add `{LEGACY_ROADMAP_PATH}` explicitly",
resolution.manifest_path.display()
),
};
Some(format!(
"legacy roadmap file {} exists but is not loaded by {source_order}; move any remaining sequencing guidance into `backlog.md` or GitHub priority labels / `ccd-backlog` metadata, or {manifest_hint} if it still belongs in session context",
legacy_path.display()
))
}
fn resolve_from_overlay_config(
repo_root: &Path,
layout: &StateLayout,
locality_id: &str,
) -> Result<Option<ManifestResolution>> {
let config = match layout.load_repo_overlay_config(locality_id)? {
Some(config) => config,
None => return Ok(None),
};
if config.sources.always.is_empty() {
return Ok(None);
}
let config_path = layout.repo_overlay_config_path(locality_id)?;
let mut entries = Vec::new();
let mut project_truth_paths = Vec::new();
for entry_str in &config.sources.always {
let entry = validate_manifest_entry(entry_str)?;
let path = repo_root.join(&entry);
if !path.is_file() {
bail!(
"config.toml source `{}` does not exist as a file under {}",
entry.display(),
repo_root.display()
);
}
project_truth_paths.push(path);
entries.push(entry);
}
Ok(Some(ManifestResolution {
source_order: "config",
manifest_path: config_path,
manifest_status: "loaded",
entries,
project_truth_paths,
}))
}
fn extract_manifest_entry(line: &str) -> Option<&str> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("<!--") {
return None;
}
if let Some(rest) = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.or_else(|| trimmed.strip_prefix("+ "))
{
return Some(rest.trim());
}
let mut parts = trimmed.splitn(2, ". ");
let first = parts.next()?;
let second = parts.next();
if !first.is_empty() && first.chars().all(|ch| ch.is_ascii_digit()) {
return second.map(str::trim);
}
Some(trimmed)
}
fn validate_manifest_entry(entry: &str) -> Result<PathBuf> {
let trimmed = entry.trim().trim_matches('`').trim();
if trimmed.is_empty() {
bail!("manifest entries cannot be empty");
}
let path = Path::new(trimmed);
if path.is_absolute() {
bail!("manifest entries must be relative paths: `{trimmed}`");
}
for component in path.components() {
match component {
Component::Normal(_) | Component::CurDir => {}
Component::ParentDir => {
bail!("manifest entries cannot use parent-directory traversal: `{trimmed}`")
}
Component::RootDir | Component::Prefix(_) => {
bail!("manifest entries must be relative paths: `{trimmed}`")
}
}
}
Ok(PathBuf::from(trimmed))
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
use crate::profile::ProfileName;
#[test]
fn config_toml_sources_takes_priority_over_manifest_md() {
let temp = tempdir().expect("tempdir");
let repo_root = temp.path().join("repo");
fs::create_dir_all(&repo_root).expect("repo dir");
fs::write(repo_root.join("AGENTS.md"), "# Agents").expect("AGENTS.md");
fs::write(repo_root.join("README.md"), "# README").expect("README.md");
let layout = StateLayout::new(
temp.path().join(".ccd"),
temp.path().join("repo/.git/ccd"),
ProfileName::new("main").expect("profile"),
);
let locality_id = "ccdrepo_src";
fs::create_dir_all(layout.repo_overlay_root(locality_id).expect("repo overlay"))
.expect("repo overlay");
fs::write(
layout
.repo_overlay_config_path(locality_id)
.expect("config path"),
r#"
[sources]
always = ["AGENTS.md", "README.md"]
"#,
)
.expect("config.toml");
let resolution = resolve_manifest(&repo_root, &layout, locality_id).expect("resolve");
assert_eq!(resolution.source_order, "config");
assert!(resolution.manifest_path.ends_with("config.toml"));
assert_eq!(resolution.entries.len(), 2);
assert_eq!(resolution.project_truth_paths.len(), 2);
}
}