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}