use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::json;
use super::gendoc::templates;
use super::project::resolve_project_root;
pub const PRESET_CATALOG_VERSION: &str = "preset-catalog@2026-04-23";
#[derive(Debug, Clone)]
pub struct HubDistPresetResolution {
pub projections: Option<Vec<String>>,
pub config_path: Option<String>,
pub lint_strict: Option<bool>,
pub catalog_version: String,
pub preset_name: Option<String>,
pub overrides_source: Vec<String>,
pub resolved_project_root: Option<PathBuf>,
}
#[derive(Debug, Deserialize)]
struct HubDistToml {
hub: Option<HubSection>,
}
#[derive(Debug, Deserialize, Default)]
struct HubSection {
dist: Option<HubDistSection>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
context7: Option<HubContext7Config>,
#[serde(default)]
devin: Option<HubDevinConfig>,
}
#[derive(Debug, Deserialize)]
struct HubDistSection {
preset_catalog_version: Option<String>,
presets: Option<BTreeMap<String, HubDistPresetOverride>>,
}
#[derive(Debug, Deserialize)]
struct HubDistPresetOverride {
projections: Option<Vec<String>>,
config_path: Option<String>,
lint_strict: Option<bool>,
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct HubContext7Config {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub rules_override: Option<Vec<String>>,
#[serde(default)]
pub rules_file: Option<String>,
#[serde(default)]
pub extra_rules: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct HubDevinConfig {
#[serde(default)]
pub repo_notes_override: Option<Vec<String>>,
#[serde(default)]
pub repo_notes_file: Option<String>,
#[serde(default)]
pub extra_repo_notes: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct ResolvedContext7 {
pub name: String,
pub description: String,
pub rules: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ResolvedDevin {
pub repo_notes: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct HubProjectionConfig {
pub context7: ResolvedContext7,
pub devin: ResolvedDevin,
}
impl HubProjectionConfig {
pub fn to_context7_toml(&self) -> toml::Value {
let mut map = toml::value::Table::new();
map.insert(
"projectTitle".to_string(),
toml::Value::String(self.context7.name.clone()),
);
map.insert(
"description".to_string(),
toml::Value::String(self.context7.description.clone()),
);
let rules: Vec<toml::Value> = self
.context7
.rules
.iter()
.map(|r| toml::Value::String(r.clone()))
.collect();
map.insert("rules".to_string(), toml::Value::Array(rules));
toml::Value::Table(map)
}
pub fn to_devin_toml(&self) -> toml::Value {
let mut map = toml::value::Table::new();
let repo_notes: Vec<toml::Value> = self
.devin
.repo_notes
.iter()
.map(|s| {
let mut t = toml::value::Table::new();
t.insert("content".to_string(), toml::Value::String(s.clone()));
toml::Value::Table(t)
})
.collect();
map.insert("repo_notes".to_string(), toml::Value::Array(repo_notes));
toml::Value::Table(map)
}
}
pub fn load_hub_projection_config(
project_root: Option<&Path>,
) -> Result<HubProjectionConfig, String> {
let hub_section: Option<HubSection> = if let Some(root) = project_root {
let alc_path = root.join("alc.toml");
if alc_path.is_file() {
let raw = std::fs::read_to_string(&alc_path)
.map_err(|e| format!("gendoc: failed to read {}: {e}", alc_path.display()))?;
let parsed: HubDistToml =
toml::from_str(&raw).map_err(|e| format!("gendoc: alc.toml parse failed: {e}"))?;
parsed.hub
} else {
None
}
} else {
None
};
let hub = hub_section.as_ref();
let shared_name = hub.and_then(|h| h.name.as_deref());
let shared_description = hub.and_then(|h| h.description.as_deref());
let c7_cfg = hub.and_then(|h| h.context7.as_ref());
let dv_cfg = hub.and_then(|h| h.devin.as_ref());
if let Some(c7) = c7_cfg {
if c7.rules_file.is_some() && c7.rules_override.is_some() {
return Err("gendoc: rules_file and rules_override are mutually exclusive".to_string());
}
}
if let Some(dv) = dv_cfg {
if dv.repo_notes_file.is_some() && dv.repo_notes_override.is_some() {
return Err(
"gendoc: repo_notes_file and repo_notes_override are mutually exclusive"
.to_string(),
);
}
}
let c7_name = c7_cfg
.and_then(|c| c.name.as_deref())
.or(shared_name)
.unwrap_or(templates::DEFAULT_NAME_FALLBACK)
.to_string();
let c7_description = c7_cfg
.and_then(|c| c.description.as_deref())
.or(shared_description)
.unwrap_or(templates::DEFAULT_C7_DESCRIPTION)
.to_string();
let c7_rules = resolve_rules(
c7_cfg.and_then(|c| c.rules_file.as_deref()),
c7_cfg.and_then(|c| c.rules_override.as_deref()),
c7_cfg.and_then(|c| c.extra_rules.as_deref()),
templates::DEFAULT_C7_RULES,
project_root,
)?;
let dv_repo_notes = resolve_rules(
dv_cfg.and_then(|d| d.repo_notes_file.as_deref()),
dv_cfg.and_then(|d| d.repo_notes_override.as_deref()),
dv_cfg.and_then(|d| d.extra_repo_notes.as_deref()),
templates::DEFAULT_DEVIN_REPO_NOTES,
project_root,
)?;
Ok(HubProjectionConfig {
context7: ResolvedContext7 {
name: c7_name,
description: c7_description,
rules: c7_rules,
},
devin: ResolvedDevin {
repo_notes: dv_repo_notes,
},
})
}
fn resolve_rules(
file_path: Option<&str>,
override_list: Option<&[String]>,
extra: Option<&[String]>,
default_list: &[&str],
project_root: Option<&Path>,
) -> Result<Vec<String>, String> {
if let Some(rel_path) = file_path {
let abs_path = if let Some(root) = project_root {
root.join(rel_path)
} else {
Path::new(rel_path).to_path_buf()
};
let content = std::fs::read_to_string(&abs_path).map_err(|e| {
format!(
"gendoc: rules_file '{}' load failed: {e}",
abs_path.display()
)
})?;
let lines: Vec<String> = content
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| l.to_string())
.collect();
return Ok(lines);
}
if let Some(ov) = override_list {
return Ok(ov.to_vec());
}
let mut result: Vec<String> = default_list.iter().map(|s| s.to_string()).collect();
if let Some(ex) = extra {
result.extend(ex.iter().cloned());
}
Ok(result)
}
pub fn resolve_hub_dist_preset(
preset: Option<&str>,
project_root: Option<&str>,
source_dir: &str,
projections: Option<&[String]>,
config_path: Option<&str>,
lint_strict: Option<bool>,
) -> Result<HubDistPresetResolution, String> {
let mut overrides_source: Vec<String> = Vec::new();
let resolved_root = resolve_project_root(project_root);
if resolved_root.is_some() {
overrides_source.push("project_root".to_string());
}
let preset_name = preset.map(|s| s.trim()).filter(|s| !s.is_empty());
let caller_projections = projections.map(|p| p.to_vec());
let caller_config_path = config_path.map(|s| s.to_string());
let caller_lint_strict = lint_strict;
let mut eff_projections = caller_projections.clone();
let mut eff_config_path = caller_config_path.clone();
let mut eff_lint_strict = caller_lint_strict;
if let Some(name) = preset_name {
if name != "publish" {
return Err(format!(
"dist: unknown preset '{name}' (allowed: publish); bump {PRESET_CATALOG_VERSION} if adding presets"
));
}
}
if let Some(root) = resolved_root.as_deref() {
let alc_path = root.join("alc.toml");
if alc_path.is_file() {
let raw = std::fs::read_to_string(&alc_path)
.map_err(|e| format!("dist: failed to read {}: {e}", alc_path.display()))?;
let parsed: HubDistToml =
toml::from_str(&raw).map_err(|e| format!("dist: failed to parse alc.toml: {e}"))?;
if let Some(hub) = parsed.hub.as_ref() {
if let Some(dist) = hub.dist.as_ref() {
if let Some(v) = dist.preset_catalog_version.as_deref() {
if !v.trim().is_empty() && v.trim() != PRESET_CATALOG_VERSION {
return Err(format!(
"dist: alc.toml hub.dist.preset_catalog_version={v:?} does not match builtin {PRESET_CATALOG_VERSION}"
));
}
}
if let Some(name) = preset_name {
if let Some(map) = dist.presets.as_ref() {
if let Some(ov) = map.get(name) {
overrides_source.push("alc.toml".to_string());
if caller_projections.is_none() {
if let Some(p) = ov.projections.as_ref() {
eff_projections = Some(p.clone());
}
}
if caller_config_path.is_none() {
eff_config_path = ov.config_path.clone();
}
if caller_lint_strict.is_none() {
eff_lint_strict = ov.lint_strict;
}
}
}
}
}
}
}
}
if preset_name.is_some() {
overrides_source.push("builtin".to_string());
if eff_projections.is_none() {
eff_projections = Some(vec!["hub".to_string(), "lint".to_string()]);
}
if eff_lint_strict.is_none() {
eff_lint_strict = Some(false);
}
}
if let Some(p) = eff_config_path.as_deref() {
let path = Path::new(p);
if !path.is_absolute() {
let source_base = Path::new(source_dir);
let candidate_source = source_base.join(path);
let candidate_project = resolved_root.as_deref().map(|root| root.join(path));
let chosen = if candidate_source.is_file() {
candidate_source
} else if let Some(c) = candidate_project {
if c.is_file() {
c
} else {
candidate_source
}
} else {
candidate_source
};
eff_config_path = Some(chosen.to_string_lossy().to_string());
}
}
Ok(HubDistPresetResolution {
projections: eff_projections,
config_path: eff_config_path,
lint_strict: eff_lint_strict,
catalog_version: PRESET_CATALOG_VERSION.to_string(),
preset_name: preset_name.map(|s| s.to_string()),
overrides_source,
resolved_project_root: resolved_root,
})
}
pub fn preset_meta_value(resolution: &HubDistPresetResolution) -> serde_json::Value {
json!({
"name": resolution.preset_name.as_deref(),
"catalog_version": resolution.catalog_version,
"resolved": {
"projections": resolution.projections,
"config_path": resolution.config_path,
"lint_strict": resolution.lint_strict,
"project_root": resolution.resolved_project_root.as_ref().map(|p| p.display().to_string()),
"overrides_source": resolution.overrides_source,
"preset_ref": serde_json::Value::Null,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn publish_defaults_to_hub_and_lint_when_projections_omitted() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(root.join("alc.toml"), "[packages]\n").expect("write alc.toml");
let source_dir = root.join("src");
std::fs::create_dir_all(&source_dir).expect("mkdir");
let res = resolve_hub_dist_preset(
Some("publish"),
Some(root.to_str().unwrap()),
source_dir.to_str().unwrap(),
None,
None,
None,
)
.expect("resolve");
assert_eq!(
res.projections,
Some(vec!["hub".to_string(), "lint".to_string()])
);
assert_eq!(res.lint_strict, Some(false));
assert!(res.config_path.is_none());
}
#[test]
fn alc_toml_preset_section_overrides_projections() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(
root.join("alc.toml"),
r#"[packages]
[hub.dist]
[hub.dist.presets.publish]
projections = ["context7"]
config_path = "configs.toml"
"#,
)
.expect("write alc.toml");
let source_dir = root.join("hub");
std::fs::create_dir_all(&source_dir).expect("mkdir");
std::fs::write(
root.join("configs.toml"),
"[context7]\nprojectTitle=\"x\"\nrules=[]\n",
)
.expect("write configs");
let res = resolve_hub_dist_preset(
Some("publish"),
Some(root.to_str().unwrap()),
source_dir.to_str().unwrap(),
None,
None,
None,
)
.expect("resolve");
assert_eq!(res.projections, Some(vec!["context7".to_string()]));
assert_eq!(
res.config_path.as_deref(),
Some(root.join("configs.toml").to_str().unwrap())
);
}
#[test]
fn load_projection_config_default_only() {
let cfg = load_hub_projection_config(None).expect("load");
assert_eq!(cfg.context7.name, templates::DEFAULT_NAME_FALLBACK);
assert_eq!(cfg.context7.description, templates::DEFAULT_C7_DESCRIPTION);
assert_eq!(
cfg.context7.rules,
templates::DEFAULT_C7_RULES
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
);
assert_eq!(
cfg.devin.repo_notes,
templates::DEFAULT_DEVIN_REPO_NOTES
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
);
}
#[test]
fn load_projection_config_name_only_override() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(
root.join("alc.toml"),
r#"[hub]
name = "my-project"
"#,
)
.expect("write alc.toml");
let cfg = load_hub_projection_config(Some(root)).expect("load");
assert_eq!(cfg.context7.name, "my-project");
assert_eq!(cfg.context7.description, templates::DEFAULT_C7_DESCRIPTION);
}
#[test]
fn load_projection_config_extra_rules_append() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(
root.join("alc.toml"),
r#"[hub.context7]
extra_rules = ["Custom rule A", "Custom rule B"]
"#,
)
.expect("write alc.toml");
let cfg = load_hub_projection_config(Some(root)).expect("load");
let mut expected: Vec<String> = templates::DEFAULT_C7_RULES
.iter()
.map(|s| s.to_string())
.collect();
expected.push("Custom rule A".to_string());
expected.push("Custom rule B".to_string());
assert_eq!(cfg.context7.rules, expected);
}
#[test]
fn load_projection_config_rules_override_replaces() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(
root.join("alc.toml"),
r#"[hub.context7]
rules_override = ["Only this rule"]
"#,
)
.expect("write alc.toml");
let cfg = load_hub_projection_config(Some(root)).expect("load");
assert_eq!(cfg.context7.rules, vec!["Only this rule".to_string()]);
}
#[test]
fn load_projection_config_rules_file_reads() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(
root.join("my_rules.txt"),
"Rule one\n# ignored comment\n\nRule two\n",
)
.expect("write rules file");
std::fs::write(
root.join("alc.toml"),
r#"[hub.context7]
rules_file = "my_rules.txt"
"#,
)
.expect("write alc.toml");
let cfg = load_hub_projection_config(Some(root)).expect("load");
assert_eq!(
cfg.context7.rules,
vec!["Rule one".to_string(), "Rule two".to_string()]
);
}
#[test]
fn load_projection_config_mutually_exclusive_error() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(root.join("rules.txt"), "Rule\n").expect("write rules file");
std::fs::write(
root.join("alc.toml"),
r#"[hub.context7]
rules_file = "rules.txt"
rules_override = ["Also a rule"]
"#,
)
.expect("write alc.toml");
let err = load_hub_projection_config(Some(root)).unwrap_err();
assert!(
err.contains("mutually exclusive"),
"expected mutually-exclusive error, got: {err}"
);
}
#[test]
fn load_projection_config_devin_equivalent() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(
root.join("alc.toml"),
r#"[hub.devin]
extra_repo_notes = ["Extra note"]
"#,
)
.expect("write alc.toml");
let cfg = load_hub_projection_config(Some(root)).expect("load extra");
let mut expected: Vec<String> = templates::DEFAULT_DEVIN_REPO_NOTES
.iter()
.map(|s| s.to_string())
.collect();
expected.push("Extra note".to_string());
assert_eq!(cfg.devin.repo_notes, expected);
let tmp2 = tempfile::tempdir().expect("tempdir2");
let root2 = tmp2.path();
std::fs::write(
root2.join("alc.toml"),
r#"[hub.devin]
repo_notes_override = ["Only note"]
"#,
)
.expect("write alc.toml");
let cfg2 = load_hub_projection_config(Some(root2)).expect("load override");
assert_eq!(cfg2.devin.repo_notes, vec!["Only note".to_string()]);
let tmp3 = tempfile::tempdir().expect("tempdir3");
let root3 = tmp3.path();
std::fs::write(root3.join("notes.txt"), "Note A\nNote B\n").expect("write notes");
std::fs::write(
root3.join("alc.toml"),
r#"[hub.devin]
repo_notes_file = "notes.txt"
"#,
)
.expect("write alc.toml");
let cfg3 = load_hub_projection_config(Some(root3)).expect("load file");
assert_eq!(
cfg3.devin.repo_notes,
vec!["Note A".to_string(), "Note B".to_string()]
);
let tmp4 = tempfile::tempdir().expect("tempdir4");
let root4 = tmp4.path();
std::fs::write(root4.join("notes.txt"), "Note\n").expect("write notes");
std::fs::write(
root4.join("alc.toml"),
r#"[hub.devin]
repo_notes_file = "notes.txt"
repo_notes_override = ["conflict"]
"#,
)
.expect("write alc.toml");
let err = load_hub_projection_config(Some(root4)).unwrap_err();
assert!(
err.contains("mutually exclusive"),
"expected devin mutually-exclusive error, got: {err}"
);
}
#[test]
fn hub_section_backward_compat_dist_only() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(
root.join("alc.toml"),
r#"[packages]
[hub.dist]
[hub.dist.presets.publish]
projections = ["hub", "lint"]
"#,
)
.expect("write alc.toml");
let source_dir = root.join("hub");
std::fs::create_dir_all(&source_dir).expect("mkdir");
let res = resolve_hub_dist_preset(
Some("publish"),
Some(root.to_str().unwrap()),
source_dir.to_str().unwrap(),
None,
None,
None,
)
.expect("resolve");
assert_eq!(
res.projections,
Some(vec!["hub".to_string(), "lint".to_string()])
);
let cfg = load_hub_projection_config(Some(root)).expect("load projection");
assert_eq!(cfg.context7.name, templates::DEFAULT_NAME_FALLBACK);
}
#[test]
fn to_devin_toml_wraps_repo_notes_as_content_table() {
let resolved = ResolvedDevin {
repo_notes: vec!["a".to_string(), "b".to_string()],
};
let cfg = HubProjectionConfig {
context7: ResolvedContext7 {
name: "test".to_string(),
description: "desc".to_string(),
rules: vec![],
},
devin: resolved,
};
let val = cfg.to_devin_toml();
let table = match &val {
toml::Value::Table(t) => t,
_ => panic!("expected Table"),
};
let repo_notes = match table.get("repo_notes") {
Some(toml::Value::Array(arr)) => arr,
_ => panic!("expected repo_notes array"),
};
assert_eq!(repo_notes.len(), 2);
for (item, expected_content) in repo_notes.iter().zip(["a", "b"].iter()) {
match item {
toml::Value::Table(t) => {
let content = t.get("content").expect("missing content key");
assert_eq!(
content,
&toml::Value::String(expected_content.to_string()),
"content mismatch for note"
);
assert_eq!(t.len(), 1, "unexpected extra keys in note table");
}
_ => panic!("expected each repo_note to be a Table, got: {item:?}"),
}
}
}
#[test]
fn to_context7_toml_wires_project_title_from_hub_name() {
let cfg = HubProjectionConfig {
context7: ResolvedContext7 {
name: "my-project".to_string(),
description: "A description".to_string(),
rules: vec!["Rule 1".to_string()],
},
devin: ResolvedDevin { repo_notes: vec![] },
};
let val = cfg.to_context7_toml();
let table = match &val {
toml::Value::Table(t) => t,
_ => panic!("expected Table"),
};
assert!(
table.get("name").is_none(),
"unexpected 'name' key in context7 output"
);
assert_eq!(
table.get("projectTitle"),
Some(&toml::Value::String("my-project".to_string())),
"expected projectTitle = 'my-project'"
);
assert_eq!(
table.get("description"),
Some(&toml::Value::String("A description".to_string())),
"expected description to be present"
);
}
#[test]
fn to_context7_toml_uses_default_name_fallback_when_no_name_configured() {
let cfg = load_hub_projection_config(None).expect("load");
let val = cfg.to_context7_toml();
let table = match &val {
toml::Value::Table(t) => t,
_ => panic!("expected Table"),
};
assert_eq!(
table.get("projectTitle"),
Some(&toml::Value::String(
templates::DEFAULT_NAME_FALLBACK.to_string()
)),
"expected DEFAULT_NAME_FALLBACK as projectTitle when no name configured"
);
}
#[test]
fn to_context7_toml_propagates_hub_name_via_load() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(
root.join("alc.toml"),
r#"[hub]
name = "test-hub"
"#,
)
.expect("write alc.toml");
let cfg = load_hub_projection_config(Some(root)).expect("load");
let val = cfg.to_context7_toml();
let table = match &val {
toml::Value::Table(t) => t,
_ => panic!("expected Table"),
};
assert_eq!(
table.get("projectTitle"),
Some(&toml::Value::String("test-hub".to_string())),
"expected [hub].name to propagate to projectTitle"
);
}
}