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