Skip to main content

agent_docs/commands/
scaffold_baseline.rs

1use std::fmt;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::Serialize;
6
7use crate::commands::scaffold_agents;
8use crate::env::ResolvedRoots;
9use crate::model::{BaselineTarget, Context, Scope};
10use crate::paths::normalize_path;
11
12const AGENTS_FILE_NAME: &str = "AGENTS.md";
13const DEVELOPMENT_FILE_NAME: &str = "DEVELOPMENT.md";
14const CLI_TOOLS_FILE_NAME: &str = "CLI_TOOLS.md";
15const DEVELOPMENT_TEMPLATE: &str = include_str!("../templates/development_default.md");
16const CLI_TOOLS_TEMPLATE: &str = include_str!("../templates/cli_tools_default.md");
17const SETUP_PLACEHOLDER: &str = "{{SETUP_COMMANDS}}";
18const BUILD_PLACEHOLDER: &str = "{{BUILD_COMMANDS}}";
19const TEST_PLACEHOLDER: &str = "{{TEST_COMMANDS}}";
20const CHECKS_SCRIPT_PATH: &str =
21    ".agents/skills/nils-cli-verify-required-checks/scripts/nils-cli-verify-required-checks.sh";
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ScaffoldBaselineRequest {
25    pub target: BaselineTarget,
26    pub missing_only: bool,
27    pub force: bool,
28    pub dry_run: bool,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32pub enum ScaffoldBaselineAction {
33    Created,
34    Overwritten,
35    Skipped,
36    PlannedCreate,
37    PlannedOverwrite,
38    PlannedSkip,
39}
40
41impl ScaffoldBaselineAction {
42    pub const fn as_str(self) -> &'static str {
43        match self {
44            Self::Created => "created",
45            Self::Overwritten => "overwritten",
46            Self::Skipped => "skipped",
47            Self::PlannedCreate => "planned-create",
48            Self::PlannedOverwrite => "planned-overwrite",
49            Self::PlannedSkip => "planned-skip",
50        }
51    }
52}
53
54impl fmt::Display for ScaffoldBaselineAction {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.write_str(self.as_str())
57    }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct ScaffoldBaselineItemReport {
62    pub scope: Scope,
63    pub context: Context,
64    pub label: String,
65    pub path: PathBuf,
66    pub action: ScaffoldBaselineAction,
67    pub reason: String,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71pub struct ScaffoldBaselineReport {
72    pub target: BaselineTarget,
73    pub missing_only: bool,
74    pub force: bool,
75    pub dry_run: bool,
76    pub agent_home: PathBuf,
77    pub project_path: PathBuf,
78    pub items: Vec<ScaffoldBaselineItemReport>,
79    pub created: usize,
80    pub overwritten: usize,
81    pub skipped: usize,
82    pub planned_create: usize,
83    pub planned_overwrite: usize,
84    pub planned_skip: usize,
85}
86
87impl ScaffoldBaselineReport {
88    fn from_items(
89        request: &ScaffoldBaselineRequest,
90        roots: &ResolvedRoots,
91        items: Vec<ScaffoldBaselineItemReport>,
92    ) -> Self {
93        let created = items
94            .iter()
95            .filter(|item| matches!(item.action, ScaffoldBaselineAction::Created))
96            .count();
97        let overwritten = items
98            .iter()
99            .filter(|item| matches!(item.action, ScaffoldBaselineAction::Overwritten))
100            .count();
101        let skipped = items
102            .iter()
103            .filter(|item| matches!(item.action, ScaffoldBaselineAction::Skipped))
104            .count();
105        let planned_create = items
106            .iter()
107            .filter(|item| matches!(item.action, ScaffoldBaselineAction::PlannedCreate))
108            .count();
109        let planned_overwrite = items
110            .iter()
111            .filter(|item| matches!(item.action, ScaffoldBaselineAction::PlannedOverwrite))
112            .count();
113        let planned_skip = items
114            .iter()
115            .filter(|item| matches!(item.action, ScaffoldBaselineAction::PlannedSkip))
116            .count();
117
118        Self {
119            target: request.target,
120            missing_only: request.missing_only,
121            force: request.force,
122            dry_run: request.dry_run,
123            agent_home: roots.agent_home.clone(),
124            project_path: roots.project_path.clone(),
125            items,
126            created,
127            overwritten,
128            skipped,
129            planned_create,
130            planned_overwrite,
131            planned_skip,
132        }
133    }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum ScaffoldBaselineErrorKind {
138    Io,
139}
140
141impl ScaffoldBaselineErrorKind {
142    pub const fn as_str(self) -> &'static str {
143        match self {
144            Self::Io => "io",
145        }
146    }
147}
148
149impl fmt::Display for ScaffoldBaselineErrorKind {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        f.write_str(self.as_str())
152    }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct ScaffoldBaselineError {
157    pub kind: ScaffoldBaselineErrorKind,
158    pub path: PathBuf,
159    pub message: String,
160}
161
162impl ScaffoldBaselineError {
163    fn io(path: PathBuf, message: impl Into<String>) -> Self {
164        Self {
165            kind: ScaffoldBaselineErrorKind::Io,
166            path,
167            message: message.into(),
168        }
169    }
170}
171
172impl fmt::Display for ScaffoldBaselineError {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        write!(
175            f,
176            "{} [{}]: {}",
177            self.path.display(),
178            self.kind,
179            self.message
180        )
181    }
182}
183
184impl std::error::Error for ScaffoldBaselineError {}
185
186pub fn scaffold_baseline(
187    request: &ScaffoldBaselineRequest,
188    roots: &ResolvedRoots,
189) -> Result<ScaffoldBaselineReport, ScaffoldBaselineError> {
190    let mut items = Vec::new();
191    for candidate in collect_candidates(request.target, roots) {
192        items.push(scaffold_candidate(request, &candidate)?);
193    }
194
195    Ok(ScaffoldBaselineReport::from_items(request, roots, items))
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199enum BaselineTemplate {
200    Agents,
201    Development,
202    CliTools,
203}
204
205#[derive(Debug, Clone)]
206struct BaselineCandidate {
207    scope: Scope,
208    context: Context,
209    label: &'static str,
210    path: PathBuf,
211    root: PathBuf,
212    template: BaselineTemplate,
213}
214
215fn collect_candidates(target: BaselineTarget, roots: &ResolvedRoots) -> Vec<BaselineCandidate> {
216    let mut candidates = Vec::new();
217    match target {
218        BaselineTarget::Home => candidates.extend(home_candidates(roots)),
219        BaselineTarget::Project => candidates.extend(project_candidates(roots)),
220        BaselineTarget::All => {
221            candidates.extend(home_candidates(roots));
222            candidates.extend(project_candidates(roots));
223        }
224    }
225    candidates
226}
227
228fn home_candidates(roots: &ResolvedRoots) -> Vec<BaselineCandidate> {
229    vec![
230        candidate(
231            Scope::Home,
232            Context::Startup,
233            "startup policy",
234            &roots.agent_home,
235            AGENTS_FILE_NAME,
236            BaselineTemplate::Agents,
237        ),
238        candidate(
239            Scope::Home,
240            Context::SkillDev,
241            "skill-dev",
242            &roots.agent_home,
243            DEVELOPMENT_FILE_NAME,
244            BaselineTemplate::Development,
245        ),
246        candidate(
247            Scope::Home,
248            Context::TaskTools,
249            "task-tools",
250            &roots.agent_home,
251            CLI_TOOLS_FILE_NAME,
252            BaselineTemplate::CliTools,
253        ),
254    ]
255}
256
257fn project_candidates(roots: &ResolvedRoots) -> Vec<BaselineCandidate> {
258    vec![
259        candidate(
260            Scope::Project,
261            Context::Startup,
262            "startup policy",
263            &roots.project_path,
264            AGENTS_FILE_NAME,
265            BaselineTemplate::Agents,
266        ),
267        candidate(
268            Scope::Project,
269            Context::ProjectDev,
270            "project-dev",
271            &roots.project_path,
272            DEVELOPMENT_FILE_NAME,
273            BaselineTemplate::Development,
274        ),
275    ]
276}
277
278fn candidate(
279    scope: Scope,
280    context: Context,
281    label: &'static str,
282    root: &Path,
283    file_name: &str,
284    template: BaselineTemplate,
285) -> BaselineCandidate {
286    BaselineCandidate {
287        scope,
288        context,
289        label,
290        path: normalize_path(&root.join(file_name)),
291        root: root.to_path_buf(),
292        template,
293    }
294}
295
296fn scaffold_candidate(
297    request: &ScaffoldBaselineRequest,
298    candidate: &BaselineCandidate,
299) -> Result<ScaffoldBaselineItemReport, ScaffoldBaselineError> {
300    let existed_before = candidate.path.exists();
301    if existed_before && request.missing_only {
302        return Ok(report_item(
303            candidate,
304            if request.dry_run {
305                ScaffoldBaselineAction::PlannedSkip
306            } else {
307                ScaffoldBaselineAction::Skipped
308            },
309            if request.dry_run {
310                "dry-run: would skip existing file because --missing-only is set".to_string()
311            } else {
312                "skipped existing file because --missing-only is set".to_string()
313            },
314        ));
315    }
316
317    if existed_before && !request.force {
318        return Ok(report_item(
319            candidate,
320            if request.dry_run {
321                ScaffoldBaselineAction::PlannedSkip
322            } else {
323                ScaffoldBaselineAction::Skipped
324            },
325            if request.dry_run {
326                "dry-run: would skip existing file; pass --force to overwrite".to_string()
327            } else {
328                "skipped existing file; pass --force to overwrite".to_string()
329            },
330        ));
331    }
332
333    if request.dry_run {
334        let action = if existed_before {
335            ScaffoldBaselineAction::PlannedOverwrite
336        } else {
337            ScaffoldBaselineAction::PlannedCreate
338        };
339        let reason = if existed_before {
340            format!(
341                "dry-run: would overwrite {} from default template",
342                candidate.label
343            )
344        } else {
345            format!(
346                "dry-run: would create {} from default template",
347                candidate.label
348            )
349        };
350        return Ok(report_item(candidate, action, reason));
351    }
352
353    let body = render_template(candidate);
354    ensure_parent_dir(&candidate.path)?;
355    fs::write(&candidate.path, body).map_err(|err| {
356        ScaffoldBaselineError::io(
357            candidate.path.clone(),
358            format!("failed to write baseline document: {err}"),
359        )
360    })?;
361
362    let action = if existed_before {
363        ScaffoldBaselineAction::Overwritten
364    } else {
365        ScaffoldBaselineAction::Created
366    };
367    let reason = if existed_before {
368        format!("overwrote {} from default template", candidate.label)
369    } else {
370        format!("created {} from default template", candidate.label)
371    };
372
373    Ok(report_item(candidate, action, reason))
374}
375
376fn report_item(
377    candidate: &BaselineCandidate,
378    action: ScaffoldBaselineAction,
379    reason: String,
380) -> ScaffoldBaselineItemReport {
381    ScaffoldBaselineItemReport {
382        scope: candidate.scope,
383        context: candidate.context,
384        label: candidate.label.to_string(),
385        path: candidate.path.clone(),
386        action,
387        reason,
388    }
389}
390
391fn render_template(candidate: &BaselineCandidate) -> String {
392    match candidate.template {
393        BaselineTemplate::Agents => scaffold_agents::default_template().to_string(),
394        BaselineTemplate::Development => render_with_commands(
395            DEVELOPMENT_TEMPLATE,
396            &detect_workflow_commands(&candidate.root),
397        ),
398        BaselineTemplate::CliTools => render_with_commands(
399            CLI_TOOLS_TEMPLATE,
400            &detect_workflow_commands(&candidate.root),
401        ),
402    }
403}
404
405fn render_with_commands(template: &str, commands: &WorkflowCommands) -> String {
406    template
407        .replace(SETUP_PLACEHOLDER, &commands.setup.join("\n"))
408        .replace(BUILD_PLACEHOLDER, &commands.build.join("\n"))
409        .replace(TEST_PLACEHOLDER, &commands.test.join("\n"))
410}
411
412#[derive(Debug, Clone, PartialEq, Eq)]
413struct WorkflowCommands {
414    setup: Vec<String>,
415    build: Vec<String>,
416    test: Vec<String>,
417}
418
419fn detect_workflow_commands(root: &Path) -> WorkflowCommands {
420    if root.join("Cargo.toml").exists() {
421        let mut test = Vec::new();
422        if root.join(CHECKS_SCRIPT_PATH).exists() {
423            test.push(format!("./{CHECKS_SCRIPT_PATH}"));
424        }
425        test.push("cargo fmt --all -- --check".to_string());
426        test.push("cargo clippy --all-targets --all-features -- -D warnings".to_string());
427        test.push("cargo test --workspace".to_string());
428
429        return WorkflowCommands {
430            setup: vec!["cargo fetch".to_string()],
431            build: vec!["cargo build --workspace".to_string()],
432            test,
433        };
434    }
435
436    WorkflowCommands {
437        setup: vec!["echo \"Define setup command for this repository\"".to_string()],
438        build: vec!["echo \"Define build command for this repository\"".to_string()],
439        test: vec!["echo \"Define test command for this repository\"".to_string()],
440    }
441}
442
443fn ensure_parent_dir(path: &Path) -> Result<(), ScaffoldBaselineError> {
444    let Some(parent) = path.parent() else {
445        return Ok(());
446    };
447    if parent.as_os_str().is_empty() {
448        return Ok(());
449    }
450
451    fs::create_dir_all(parent).map_err(|err| {
452        ScaffoldBaselineError::io(
453            path.to_path_buf(),
454            format!(
455                "failed to create parent directory {}: {err}",
456                parent.display()
457            ),
458        )
459    })?;
460    Ok(())
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    use tempfile::TempDir;
468
469    fn roots(home: &TempDir, project: &TempDir) -> ResolvedRoots {
470        ResolvedRoots {
471            agent_home: home.path().to_path_buf(),
472            project_path: project.path().to_path_buf(),
473            is_linked_worktree: false,
474            git_common_dir: None,
475            primary_worktree_path: None,
476        }
477    }
478
479    fn item_for<'a>(
480        report: &'a ScaffoldBaselineReport,
481        scope: Scope,
482        file_name: &str,
483    ) -> &'a ScaffoldBaselineItemReport {
484        report
485            .items
486            .iter()
487            .find(|item| {
488                item.scope == scope
489                    && item.path.file_name().and_then(|value| value.to_str()) == Some(file_name)
490            })
491            .expect("expected report item")
492    }
493
494    #[test]
495    fn scaffold_baseline_missing_only_creates_only_missing_project_documents() {
496        let home = TempDir::new().expect("create home tempdir");
497        let project = TempDir::new().expect("create project tempdir");
498        fs::write(
499            project.path().join("Cargo.toml"),
500            "[package]\nname = \"demo\"\n",
501        )
502        .expect("seed cargo file");
503        fs::write(project.path().join("AGENTS.md"), "# custom\n").expect("seed agents");
504
505        let request = ScaffoldBaselineRequest {
506            target: BaselineTarget::Project,
507            missing_only: true,
508            force: true,
509            dry_run: false,
510        };
511
512        let report = scaffold_baseline(&request, &roots(&home, &project)).expect("scaffold");
513        assert_eq!(report.items.len(), 2);
514        assert_eq!(report.created, 1);
515        assert_eq!(report.overwritten, 0);
516        assert_eq!(report.skipped, 1);
517
518        let agents = item_for(&report, Scope::Project, AGENTS_FILE_NAME);
519        assert_eq!(agents.action, ScaffoldBaselineAction::Skipped);
520        assert!(agents.reason.contains("--missing-only"));
521        let development = item_for(&report, Scope::Project, DEVELOPMENT_FILE_NAME);
522        assert_eq!(development.action, ScaffoldBaselineAction::Created);
523        assert_eq!(
524            fs::read_to_string(project.path().join("AGENTS.md")).expect("read agents"),
525            "# custom\n"
526        );
527        let written =
528            fs::read_to_string(project.path().join("DEVELOPMENT.md")).expect("read development");
529        assert!(written.contains("cargo fetch"));
530        assert!(written.contains("cargo build --workspace"));
531        assert!(written.contains("cargo test --workspace"));
532    }
533
534    #[test]
535    fn scaffold_baseline_skips_existing_without_force() {
536        let home = TempDir::new().expect("create home tempdir");
537        let project = TempDir::new().expect("create project tempdir");
538        fs::write(project.path().join("AGENTS.md"), "# existing agents\n").expect("seed agents");
539        fs::write(project.path().join("DEVELOPMENT.md"), "# existing dev\n")
540            .expect("seed development");
541
542        let request = ScaffoldBaselineRequest {
543            target: BaselineTarget::Project,
544            missing_only: false,
545            force: false,
546            dry_run: false,
547        };
548
549        let report = scaffold_baseline(&request, &roots(&home, &project)).expect("scaffold");
550        assert_eq!(report.created, 0);
551        assert_eq!(report.overwritten, 0);
552        assert_eq!(report.skipped, 2);
553        assert_eq!(
554            item_for(&report, Scope::Project, AGENTS_FILE_NAME).action,
555            ScaffoldBaselineAction::Skipped
556        );
557        assert_eq!(
558            item_for(&report, Scope::Project, DEVELOPMENT_FILE_NAME).action,
559            ScaffoldBaselineAction::Skipped
560        );
561        assert_eq!(
562            fs::read_to_string(project.path().join("AGENTS.md")).expect("read agents"),
563            "# existing agents\n"
564        );
565        assert_eq!(
566            fs::read_to_string(project.path().join("DEVELOPMENT.md")).expect("read development"),
567            "# existing dev\n"
568        );
569    }
570
571    #[test]
572    fn scaffold_baseline_force_overwrites_existing_documents() {
573        let home = TempDir::new().expect("create home tempdir");
574        let project = TempDir::new().expect("create project tempdir");
575        fs::write(
576            project.path().join("Cargo.toml"),
577            "[package]\nname = \"demo\"\n",
578        )
579        .expect("seed cargo file");
580        fs::write(project.path().join("AGENTS.md"), "# stale agents\n").expect("seed agents");
581        fs::write(project.path().join("DEVELOPMENT.md"), "# stale dev\n")
582            .expect("seed development");
583
584        let request = ScaffoldBaselineRequest {
585            target: BaselineTarget::Project,
586            missing_only: false,
587            force: true,
588            dry_run: false,
589        };
590
591        let report = scaffold_baseline(&request, &roots(&home, &project)).expect("scaffold");
592        assert_eq!(report.created, 0);
593        assert_eq!(report.overwritten, 2);
594        assert_eq!(report.skipped, 0);
595        assert_eq!(
596            item_for(&report, Scope::Project, AGENTS_FILE_NAME).action,
597            ScaffoldBaselineAction::Overwritten
598        );
599        assert_eq!(
600            item_for(&report, Scope::Project, DEVELOPMENT_FILE_NAME).action,
601            ScaffoldBaselineAction::Overwritten
602        );
603
604        let agents_written =
605            fs::read_to_string(project.path().join("AGENTS.md")).expect("read agents");
606        assert_eq!(agents_written, scaffold_agents::default_template());
607        let development_written =
608            fs::read_to_string(project.path().join("DEVELOPMENT.md")).expect("read development");
609        assert!(development_written.contains("cargo fmt --all -- --check"));
610        assert!(
611            development_written
612                .contains("cargo clippy --all-targets --all-features -- -D warnings")
613        );
614        assert!(development_written.contains("cargo test --workspace"));
615    }
616
617    #[test]
618    fn scaffold_baseline_dry_run_reports_plan_without_writing() {
619        let home = TempDir::new().expect("create home tempdir");
620        let project = TempDir::new().expect("create project tempdir");
621        fs::write(home.path().join("AGENTS.md"), "# existing home agents\n")
622            .expect("seed home agents");
623        fs::write(
624            project.path().join("DEVELOPMENT.md"),
625            "# existing project dev\n",
626        )
627        .expect("seed project development");
628
629        let request = ScaffoldBaselineRequest {
630            target: BaselineTarget::All,
631            missing_only: false,
632            force: true,
633            dry_run: true,
634        };
635
636        let report = scaffold_baseline(&request, &roots(&home, &project)).expect("scaffold");
637        assert_eq!(report.items.len(), 5);
638        assert_eq!(report.created, 0);
639        assert_eq!(report.overwritten, 0);
640        assert_eq!(report.skipped, 0);
641        assert_eq!(report.planned_create, 3);
642        assert_eq!(report.planned_overwrite, 2);
643        assert_eq!(report.planned_skip, 0);
644
645        assert_eq!(
646            fs::read_to_string(home.path().join("AGENTS.md")).expect("read home agents"),
647            "# existing home agents\n"
648        );
649        assert_eq!(
650            fs::read_to_string(project.path().join("DEVELOPMENT.md"))
651                .expect("read project development"),
652            "# existing project dev\n"
653        );
654        assert!(!home.path().join("DEVELOPMENT.md").exists());
655        assert!(!home.path().join("CLI_TOOLS.md").exists());
656        assert!(!project.path().join("AGENTS.md").exists());
657    }
658
659    #[test]
660    fn scaffold_baseline_uses_checks_script_when_present_for_cargo_projects() {
661        let home = TempDir::new().expect("create home tempdir");
662        let project = TempDir::new().expect("create project tempdir");
663        fs::write(
664            project.path().join("Cargo.toml"),
665            "[package]\nname = \"demo\"\n",
666        )
667        .expect("seed cargo file");
668        let checks_script = project.path().join(CHECKS_SCRIPT_PATH);
669        fs::create_dir_all(
670            checks_script
671                .parent()
672                .expect("checks script should have parent"),
673        )
674        .expect("create checks script parent");
675        fs::write(&checks_script, "#!/usr/bin/env bash\nexit 0\n").expect("seed checks script");
676
677        let request = ScaffoldBaselineRequest {
678            target: BaselineTarget::Project,
679            missing_only: false,
680            force: false,
681            dry_run: false,
682        };
683
684        scaffold_baseline(&request, &roots(&home, &project)).expect("scaffold");
685        let development_written =
686            fs::read_to_string(project.path().join("DEVELOPMENT.md")).expect("read development");
687        assert!(
688            development_written
689                .contains("./.agents/skills/nils-cli-verify-required-checks/scripts/nils-cli-verify-required-checks.sh")
690        );
691        assert!(development_written.contains("cargo test --workspace"));
692    }
693}