use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(crate) struct AlcToml {
#[serde(default)]
pub packages: BTreeMap<String, PackageDep>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<EnvSection>,
#[serde(default)]
pub setting: BTreeMap<String, BTreeMap<String, toml::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EnvSection {
#[serde(default)]
pub allow: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub(crate) enum PackageDep {
Version(String),
Path {
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
},
Git {
git: String,
#[serde(skip_serializing_if = "Option::is_none")]
rev: Option<String>,
},
}
pub(crate) fn alc_toml_path(project_root: &Path) -> PathBuf {
project_root.join("alc.toml")
}
pub(crate) fn local_alc_toml_path(project_root: &Path) -> PathBuf {
project_root.join("alc.local.toml")
}
pub(crate) fn load_alc_toml(project_root: &Path) -> Result<Option<AlcToml>, String> {
let path = alc_toml_path(project_root);
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read alc.toml at {}: {e}", path.display()))?;
let parsed: AlcToml = toml::from_str(&content)
.map_err(|e| format!("Failed to parse alc.toml at {}: {e}", path.display()))?;
Ok(Some(parsed))
}
pub(crate) fn load_alc_toml_document(
project_root: &Path,
) -> Result<Option<toml_edit::DocumentMut>, String> {
let path = alc_toml_path(project_root);
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read alc.toml at {}: {e}", path.display()))?;
let doc: toml_edit::DocumentMut = content
.parse()
.map_err(|e| format!("Failed to parse alc.toml at {}: {e}", path.display()))?;
Ok(Some(doc))
}
pub(crate) fn load_alc_local_toml(project_root: &Path) -> Result<Option<AlcToml>, String> {
let path = local_alc_toml_path(project_root);
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read alc.local.toml at {}: {e}", path.display()))?;
let parsed: AlcToml = toml::from_str(&content)
.map_err(|e| format!("Failed to parse alc.local.toml at {}: {e}", path.display()))?;
Ok(Some(parsed))
}
pub(crate) fn load_alc_local_toml_document(
project_root: &Path,
) -> Result<Option<toml_edit::DocumentMut>, String> {
let path = local_alc_toml_path(project_root);
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read alc.local.toml at {}: {e}", path.display()))?;
let doc: toml_edit::DocumentMut = content
.parse()
.map_err(|e| format!("Failed to parse alc.local.toml at {}: {e}", path.display()))?;
Ok(Some(doc))
}
fn save_alc_toml_at(path: &Path, doc: &toml_edit::DocumentMut) -> Result<(), String> {
let parent = path
.parent()
.ok_or_else(|| format!("Cannot determine parent directory for {}", path.display()))?;
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory for {}: {e}", path.display()))?;
std::fs::write(path, doc.to_string())
.map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
Ok(())
}
pub(crate) fn save_alc_toml(
project_root: &Path,
doc: &toml_edit::DocumentMut,
) -> Result<(), String> {
save_alc_toml_at(&alc_toml_path(project_root), doc)
}
pub(crate) fn save_alc_local_toml(
project_root: &Path,
doc: &toml_edit::DocumentMut,
) -> Result<(), String> {
save_alc_toml_at(&local_alc_toml_path(project_root), doc)
}
pub(crate) fn resolve_local_path_entries(project_root: &Path, local: &AlcToml) -> Vec<PathBuf> {
resolve_local_variant_pkgs(project_root, local)
.into_iter()
.map(|vp| vp.pkg_dir)
.collect()
}
pub(crate) fn resolve_local_variant_pkgs(
project_root: &Path,
local: &AlcToml,
) -> Vec<algocline_engine::VariantPkg> {
let mut out = Vec::new();
for (name, dep) in &local.packages {
let raw = match dep {
PackageDep::Path { path, .. } => path,
_ => continue,
};
let resolved = {
let p = Path::new(raw);
if p.is_absolute() {
p.to_path_buf()
} else {
project_root.join(p)
}
};
if !resolved.exists() {
tracing::warn!(
"alc.local.toml: path entry for '{}' does not exist, skipping: {}",
name,
resolved.display()
);
continue;
}
out.push(algocline_engine::VariantPkg::new(name, resolved));
}
out
}
pub(crate) fn add_package_entry(
doc: &mut toml_edit::DocumentMut,
name: &str,
dep: &PackageDep,
) -> bool {
use toml_edit::{value, Item, Table};
if doc.get("packages").is_none() {
doc.insert("packages", Item::Table(Table::new()));
}
let packages = match doc["packages"].as_table_mut() {
Some(t) => t,
None => return false,
};
if packages.contains_key(name) {
return false;
}
match dep {
PackageDep::Version(v) => {
packages.insert(name, value(v.as_str()));
}
PackageDep::Path { path, version: ver } => {
let mut tbl = toml_edit::InlineTable::new();
tbl.insert("path", path.as_str().into());
if let Some(v) = ver {
tbl.insert("version", v.as_str().into());
}
packages.insert(name, Item::Value(toml_edit::Value::InlineTable(tbl)));
}
PackageDep::Git { git, rev } => {
let mut tbl = toml_edit::InlineTable::new();
tbl.insert("git", git.as_str().into());
if let Some(r) = rev {
tbl.insert("rev", r.as_str().into());
}
packages.insert(name, Item::Value(toml_edit::Value::InlineTable(tbl)));
}
}
true
}
pub(crate) fn remove_package_entry(doc: &mut toml_edit::DocumentMut, name: &str) -> bool {
let packages = match doc.get_mut("packages").and_then(|i| i.as_table_mut()) {
Some(t) => t,
None => return false,
};
packages.remove(name).is_some()
}
pub(crate) fn validate_package_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("package name must not be empty".to_string());
}
let mut chars = name.chars();
let first = chars.next().unwrap();
if !first.is_ascii_alphabetic() {
return Err(format!(
"package name must start with a letter, got '{first}'"
));
}
for c in chars {
if !c.is_ascii_alphanumeric() && c != '_' && c != '-' {
return Err(format!(
"package name contains invalid character '{c}': only [a-zA-Z0-9_-] allowed"
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_version_dep() {
let toml = r#"
[packages]
cot = "*"
flow = "0.2.0"
"#;
let parsed: AlcToml = toml::from_str(toml).unwrap();
assert_eq!(parsed.packages["cot"], PackageDep::Version("*".to_string()));
assert_eq!(
parsed.packages["flow"],
PackageDep::Version("0.2.0".to_string())
);
}
#[test]
fn parse_path_dep() {
let toml = r#"
[packages.head_agent]
path = "packages/head_agent"
"#;
let parsed: AlcToml = toml::from_str(toml).unwrap();
assert_eq!(
parsed.packages["head_agent"],
PackageDep::Path {
path: "packages/head_agent".to_string(),
version: None,
}
);
}
#[test]
fn parse_git_dep() {
let toml = r#"
[packages.my_pkg]
git = "https://github.com/user/my-pkg"
rev = "abc123"
"#;
let parsed: AlcToml = toml::from_str(toml).unwrap();
assert_eq!(
parsed.packages["my_pkg"],
PackageDep::Git {
git: "https://github.com/user/my-pkg".to_string(),
rev: Some("abc123".to_string()),
}
);
}
#[test]
fn parse_mixed() {
let toml = r#"
[packages]
cot = "*"
[packages.head_agent]
path = "packages/head_agent"
version = "0.3.0"
[packages.my_pkg]
git = "https://github.com/user/my-pkg"
"#;
let parsed: AlcToml = toml::from_str(toml).unwrap();
assert_eq!(parsed.packages.len(), 3);
assert_eq!(parsed.packages["cot"], PackageDep::Version("*".to_string()));
assert_eq!(
parsed.packages["head_agent"],
PackageDep::Path {
path: "packages/head_agent".to_string(),
version: Some("0.3.0".to_string()),
}
);
assert_eq!(
parsed.packages["my_pkg"],
PackageDep::Git {
git: "https://github.com/user/my-pkg".to_string(),
rev: None,
}
);
}
#[test]
fn parse_invalid_format() {
let toml = r#"
[packages]
invalid_key = 42
"#;
let result: Result<AlcToml, _> = toml::from_str(toml);
assert!(result.is_err());
}
#[test]
fn load_returns_none_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let result = load_alc_toml(tmp.path()).unwrap();
assert_eq!(result, None);
}
#[test]
fn load_and_parse() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("alc.toml"), "[packages]\ncot = \"*\"\n").unwrap();
let parsed = load_alc_toml(tmp.path()).unwrap().unwrap();
assert_eq!(parsed.packages["cot"], PackageDep::Version("*".to_string()));
}
#[test]
fn document_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let content = "# comment\n[packages]\ncot = \"*\"\n";
std::fs::write(tmp.path().join("alc.toml"), content).unwrap();
let doc = load_alc_toml_document(tmp.path()).unwrap().unwrap();
save_alc_toml(tmp.path(), &doc).unwrap();
let after = std::fs::read_to_string(tmp.path().join("alc.toml")).unwrap();
assert!(after.contains("# comment"), "comment was lost: {after}");
assert!(after.contains("cot"), "cot entry was lost");
}
#[test]
fn add_version_entry() {
let mut doc: toml_edit::DocumentMut = "[packages]\n".parse().unwrap();
let added = add_package_entry(&mut doc, "cot", &PackageDep::Version("*".to_string()));
assert!(added);
assert!(doc.to_string().contains("cot"));
}
#[test]
fn add_path_entry() {
let mut doc: toml_edit::DocumentMut = "[packages]\n".parse().unwrap();
let added = add_package_entry(
&mut doc,
"head",
&PackageDep::Path {
path: "packages/head".to_string(),
version: None,
},
);
assert!(added);
assert!(doc.to_string().contains("head"));
assert!(doc.to_string().contains("packages/head"));
}
#[test]
fn add_skips_existing() {
let mut doc: toml_edit::DocumentMut = "[packages]\ncot = \"*\"\n".parse().unwrap();
let added = add_package_entry(&mut doc, "cot", &PackageDep::Version("0.1.0".to_string()));
assert!(!added, "should skip existing entry");
assert!(doc.to_string().contains("\"*\""));
}
#[test]
fn add_creates_packages_table() {
let mut doc: toml_edit::DocumentMut = "".parse().unwrap();
let added = add_package_entry(&mut doc, "cot", &PackageDep::Version("*".to_string()));
assert!(added);
assert!(doc.to_string().contains("[packages]"));
}
#[test]
fn remove_entry() {
let mut doc: toml_edit::DocumentMut = "[packages]\ncot = \"*\"\n".parse().unwrap();
let removed = remove_package_entry(&mut doc, "cot");
assert!(removed);
assert!(!doc.to_string().contains("cot"));
}
#[test]
fn remove_nonexistent_returns_false() {
let mut doc: toml_edit::DocumentMut = "[packages]\n".parse().unwrap();
let removed = remove_package_entry(&mut doc, "nonexistent");
assert!(!removed);
}
#[test]
fn valid_names() {
assert!(validate_package_name("cot").is_ok());
assert!(validate_package_name("head_agent").is_ok());
assert!(validate_package_name("my-pkg").is_ok());
assert!(validate_package_name("A123").is_ok());
}
#[test]
fn invalid_names() {
assert!(validate_package_name("").is_err());
assert!(validate_package_name("1start").is_err());
assert!(validate_package_name("_start").is_err());
assert!(validate_package_name("has space").is_err());
assert!(validate_package_name("has.dot").is_err());
}
#[test]
fn local_alc_toml_path_joins_filename() {
let root = Path::new("/tmp/some/project");
assert_eq!(
local_alc_toml_path(root),
PathBuf::from("/tmp/some/project/alc.local.toml")
);
}
#[test]
fn load_alc_local_toml_returns_none_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let result = load_alc_local_toml(tmp.path()).unwrap();
assert_eq!(result, None);
}
#[test]
fn load_alc_local_toml_parses_path_entry() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("target-dir");
std::fs::create_dir_all(&target).unwrap();
let content = format!("[packages.cot]\npath = \"{}\"\n", target.display());
std::fs::write(tmp.path().join("alc.local.toml"), &content).unwrap();
let parsed = load_alc_local_toml(tmp.path()).unwrap().unwrap();
assert_eq!(
parsed.packages["cot"],
PackageDep::Path {
path: target.display().to_string(),
version: None,
}
);
}
#[test]
fn save_alc_local_toml_creates_file() {
let tmp = tempfile::tempdir().unwrap();
let mut doc: toml_edit::DocumentMut = "[packages]\n".parse().unwrap();
add_package_entry(
&mut doc,
"foo",
&PackageDep::Path {
path: "/abs/path".to_string(),
version: None,
},
);
save_alc_local_toml(tmp.path(), &doc).unwrap();
let written = std::fs::read_to_string(tmp.path().join("alc.local.toml")).unwrap();
assert!(written.contains("foo"));
assert!(written.contains("/abs/path"));
}
#[test]
fn save_alc_local_toml_preserves_comments() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("alc.local.toml"),
"# worktree override\n[packages.foo]\npath = \"/abs\"\n",
)
.unwrap();
let doc = load_alc_local_toml_document(tmp.path()).unwrap().unwrap();
save_alc_local_toml(tmp.path(), &doc).unwrap();
let after = std::fs::read_to_string(tmp.path().join("alc.local.toml")).unwrap();
assert!(after.contains("# worktree override"));
assert!(after.contains("foo"));
}
#[test]
fn resolve_local_path_entries_absolute_path() {
let tmp = tempfile::tempdir().unwrap();
let abs = tmp.path().join("dev").join("cot");
std::fs::create_dir_all(&abs).unwrap();
let mut packages = BTreeMap::new();
packages.insert(
"cot".to_string(),
PackageDep::Path {
path: abs.display().to_string(),
version: None,
},
);
let local = AlcToml {
packages,
env: None,
setting: Default::default(),
};
let resolved = resolve_local_path_entries(tmp.path(), &local);
assert_eq!(resolved, vec![abs]);
}
#[test]
fn resolve_local_path_entries_relative_path() {
let tmp = tempfile::tempdir().unwrap();
let rel = PathBuf::from("rel/sub");
let abs = tmp.path().join(&rel);
std::fs::create_dir_all(&abs).unwrap();
let mut packages = BTreeMap::new();
packages.insert(
"sub".to_string(),
PackageDep::Path {
path: "rel/sub".to_string(),
version: None,
},
);
let local = AlcToml {
packages,
env: None,
setting: Default::default(),
};
let resolved = resolve_local_path_entries(tmp.path(), &local);
assert_eq!(resolved, vec![abs]);
}
#[test]
fn resolve_local_path_entries_missing_path_skipped() {
let tmp = tempfile::tempdir().unwrap();
let mut packages = BTreeMap::new();
packages.insert(
"ghost".to_string(),
PackageDep::Path {
path: "/definitely/does/not/exist/zzz".to_string(),
version: None,
},
);
let local = AlcToml {
packages,
env: None,
setting: Default::default(),
};
let resolved = resolve_local_path_entries(tmp.path(), &local);
assert!(resolved.is_empty());
}
#[test]
fn resolve_local_path_entries_ignores_version_and_git() {
let tmp = tempfile::tempdir().unwrap();
let abs = tmp.path().join("real");
std::fs::create_dir_all(&abs).unwrap();
let mut packages = BTreeMap::new();
packages.insert("v".to_string(), PackageDep::Version("*".to_string()));
packages.insert(
"g".to_string(),
PackageDep::Git {
git: "https://example/repo".to_string(),
rev: None,
},
);
packages.insert(
"p".to_string(),
PackageDep::Path {
path: abs.display().to_string(),
version: None,
},
);
let local = AlcToml {
packages,
env: None,
setting: Default::default(),
};
let resolved = resolve_local_path_entries(tmp.path(), &local);
assert_eq!(resolved, vec![abs]);
}
#[test]
fn resolve_local_path_entries_ordering_is_name_ascending() {
let tmp = tempfile::tempdir().unwrap();
let ucb = tmp.path().join("ucb");
let cot = tmp.path().join("cot");
std::fs::create_dir_all(&ucb).unwrap();
std::fs::create_dir_all(&cot).unwrap();
let mut packages = BTreeMap::new();
packages.insert(
"ucb".to_string(),
PackageDep::Path {
path: ucb.display().to_string(),
version: None,
},
);
packages.insert(
"cot".to_string(),
PackageDep::Path {
path: cot.display().to_string(),
version: None,
},
);
let local = AlcToml {
packages,
env: None,
setting: Default::default(),
};
let resolved = resolve_local_path_entries(tmp.path(), &local);
assert_eq!(resolved, vec![cot, ucb]);
}
#[test]
fn parse_setting_dep() {
let toml = r#"
[setting.journal]
path = "/logs"
enabled = true
count = 3
"#;
let parsed: AlcToml = toml::from_str(toml).unwrap();
let j = parsed.setting.get("journal").expect("expected 'journal'");
assert_eq!(
j.get("path"),
Some(&toml::Value::String("/logs".to_string()))
);
assert_eq!(j.get("enabled"), Some(&toml::Value::Boolean(true)));
assert_eq!(j.get("count"), Some(&toml::Value::Integer(3)));
}
#[test]
fn setting_default_empty_when_absent() {
let toml = "[packages]\ncot = \"*\"\n";
let parsed: AlcToml = toml::from_str(toml).unwrap();
assert!(
parsed.setting.is_empty(),
"setting must default to empty when absent from TOML"
);
}
}