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}