use std::collections::HashSet;
use anyhow::{bail, Context, Result};
use colored::Colorize;
use straymark_core::architecture::{parse_model, parse_model_str, ArchModel, Component};
use straymark_core::drift::glob_match;
use super::{common, drawio};
use crate::utils;
pub fn run(path: &str, out: Option<&str>, apply: bool) -> Result<()> {
let root = common::resolve_root(path);
let (_out_dir, model_path, drawio_path) = common::artifact_paths(&root, out);
if !model_path.exists() {
bail!(
"no architecture model at {} — run {} first",
model_path.display(),
"straymark architecture generate".cyan()
);
}
let model = parse_model(&model_path)
.with_context(|| format!("parsing {}", model_path.display()))?;
let scanned = common::source_component_dirs(&root);
let on_disk: Vec<String> = common::collect_source_files(&root)
.iter()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.collect();
let existing_ids: HashSet<&str> = model.components.iter().map(|c| c.id.as_str()).collect();
let new_dirs: Vec<String> = scanned
.into_iter()
.filter(|dir| !existing_ids.contains(common::kebab(dir).as_str()))
.filter(|dir| !dir_covered(&model, &on_disk, dir))
.collect();
if new_dirs.is_empty() {
utils::success("Architecture model is up to date — no new source dirs to add.");
return Ok(());
}
let mut combined = model.clone();
let base = combined.components.len();
combined
.components
.extend(new_dirs.iter().map(|d| common::component_from_dir(d)));
let enrich = common::enrich_from_adrs(&root, &mut combined);
let appended: Vec<Component> = combined.components[base..].to_vec();
if !apply {
utils::info(&format!(
"{} new component{} would be added (dry-run — pass {} to write):",
appended.len(),
common::plural(appended.len()),
"--apply".cyan()
));
for c in &appended {
print_proposal(c);
}
common::report_enrichment(&enrich);
utils::info("No files changed.");
return Ok(());
}
apply_changes(&model_path, &drawio_path, &appended)?;
utils::success(&format!(
"Appended {} component{} to {}",
appended.len(),
common::plural(appended.len()),
model_path.display()
));
for c in &appended {
print_proposal(c);
}
common::report_enrichment(&enrich);
Ok(())
}
fn dir_covered(model: &ArchModel, on_disk: &[String], dir: &str) -> bool {
let prefix = format!("{dir}/");
model.components.iter().any(|c| {
c.globs.iter().any(|g| {
on_disk
.iter()
.any(|f| f.starts_with(&prefix) && glob_match(g, f))
})
})
}
fn print_proposal(c: &Component) {
let links = if c.links.is_empty() {
String::new()
} else {
format!(" → links: {}", c.links.join(", "))
};
println!(
" {} {} (globs: {}){}",
"+".green(),
c.id.bold(),
c.globs.join(", "),
links
);
}
fn apply_changes(
model_path: &std::path::Path,
drawio_path: &std::path::Path,
appended: &[Component],
) -> Result<()> {
let mut yaml = std::fs::read_to_string(model_path)
.with_context(|| format!("reading {}", model_path.display()))?;
if !yaml.ends_with('\n') {
yaml.push('\n');
}
yaml.push_str("# Added by `straymark architecture sync`:\n");
for c in appended {
yaml.push_str(&common::render_component_block(c));
}
parse_model_str(&yaml).context(
"internal error: model.yml would be invalid after append (is `components:` the last key?)",
)?;
std::fs::write(model_path, &yaml)
.with_context(|| format!("writing {}", model_path.display()))?;
if drawio_path.exists() {
let xml = std::fs::read_to_string(drawio_path)
.with_context(|| format!("reading {}", drawio_path.display()))?;
match drawio::append_cells(&xml, appended) {
Some(updated) => {
std::fs::write(drawio_path, &updated)
.with_context(|| format!("writing {}", drawio_path.display()))?;
utils::success(&format!("Appended cells to {}", drawio_path.display()));
}
None => utils::warn(&format!(
"{} is not a recognized DrawIO document — skipped (model.yml updated)",
drawio_path.display()
)),
}
} else {
utils::warn("no plan.drawio — model.yml updated only (run `architecture generate`).");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use straymark_core::architecture::Layer;
fn model_with(globs: &[&str]) -> ArchModel {
ArchModel {
version: 0,
layers: vec![Layer { id: "core".into(), label: "Core".into(), order: 0 }],
components: vec![Component {
id: "cli".into(),
label: "CLI".into(),
layer: "core".into(),
globs: globs.iter().map(|g| g.to_string()).collect(),
links: vec![],
docs: vec![],
external: false,
}],
}
}
#[test]
fn covered_dir_not_proposed() {
let model = model_with(&["cli/**"]);
let on_disk = vec!["cli/src/main.rs".to_string(), "core/src/lib.rs".to_string()];
assert!(dir_covered(&model, &on_disk, "cli"));
assert!(!dir_covered(&model, &on_disk, "core"));
}
#[test]
fn renamed_glob_still_covers() {
let model = model_with(&["cli/src/**"]);
let on_disk = vec!["cli/src/main.rs".to_string()];
assert!(dir_covered(&model, &on_disk, "cli"));
}
}