1use crate::cli::context::ProjectTypeHint;
18use crate::config;
19use crate::constants::agents_md::{RECOMMENDED_SECTIONS, REQUIRED_SECTIONS};
20use crate::constants::versions::TEMPLATE_VERSION;
21use crate::fsutil;
22
23pub mod merge;
24pub mod wizard;
25
26use anyhow::{Context, Result};
27use std::collections::HashSet;
28use std::fs;
29use std::io::IsTerminal;
30use std::path::Path;
31use wizard::ContextPrompter;
32
33const TEMPLATE_GENERIC: &str = include_str!(concat!(
34 env!("CARGO_MANIFEST_DIR"),
35 "/assets/agents_templates/generic.md"
36));
37const TEMPLATE_RUST: &str = include_str!(concat!(
38 env!("CARGO_MANIFEST_DIR"),
39 "/assets/agents_templates/rust.md"
40));
41const TEMPLATE_PYTHON: &str = include_str!(concat!(
42 env!("CARGO_MANIFEST_DIR"),
43 "/assets/agents_templates/python.md"
44));
45const TEMPLATE_TYPESCRIPT: &str = include_str!(concat!(
46 env!("CARGO_MANIFEST_DIR"),
47 "/assets/agents_templates/typescript.md"
48));
49const TEMPLATE_GO: &str = include_str!(concat!(
50 env!("CARGO_MANIFEST_DIR"),
51 "/assets/agents_templates/go.md"
52));
53
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum DetectedProjectType {
57 Rust,
58 Python,
59 TypeScript,
60 Go,
61 Generic,
62}
63
64fn format_rfc3339_now() -> String {
66 let now = time::OffsetDateTime::now_utc();
67 format!(
69 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
70 now.year(),
71 now.month() as u8,
72 now.day(),
73 now.hour(),
74 now.minute(),
75 now.second()
76 )
77}
78
79impl std::fmt::Display for DetectedProjectType {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 DetectedProjectType::Rust => write!(f, "rust"),
83 DetectedProjectType::Python => write!(f, "python"),
84 DetectedProjectType::TypeScript => write!(f, "typescript"),
85 DetectedProjectType::Go => write!(f, "go"),
86 DetectedProjectType::Generic => write!(f, "generic"),
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum FileInitStatus {
94 Created,
95 Valid,
96}
97
98pub struct ContextInitOptions {
100 pub force: bool,
101 pub project_type_hint: Option<ProjectTypeHint>,
102 pub output_path: std::path::PathBuf,
103 pub interactive: bool,
104}
105
106pub struct ContextUpdateOptions {
108 pub sections: Vec<String>,
109 pub file: Option<std::path::PathBuf>,
110 pub interactive: bool,
111 pub dry_run: bool,
112 pub output_path: std::path::PathBuf,
113}
114
115pub struct ContextValidateOptions {
117 pub strict: bool,
118 pub path: std::path::PathBuf,
119}
120
121pub struct InitReport {
123 pub status: FileInitStatus,
124 pub detected_project_type: DetectedProjectType,
125 pub output_path: std::path::PathBuf,
126}
127
128pub struct UpdateReport {
130 pub sections_updated: Vec<String>,
131 pub dry_run: bool,
132}
133
134pub struct ValidateReport {
136 pub valid: bool,
137 pub missing_sections: Vec<String>,
138 pub outdated_sections: Vec<String>,
139}
140
141pub fn run_context_init(
143 resolved: &config::Resolved,
144 opts: ContextInitOptions,
145) -> Result<InitReport> {
146 if !opts.interactive && opts.output_path.exists() && !opts.force {
149 let detected = opts
150 .project_type_hint
151 .map(hint_to_detected)
152 .unwrap_or_else(|| detect_project_type(&resolved.repo_root));
153 return Ok(InitReport {
154 status: FileInitStatus::Valid,
155 detected_project_type: detected,
156 output_path: opts.output_path,
157 });
158 }
159
160 let detected_type = opts
162 .project_type_hint
163 .map(hint_to_detected)
164 .unwrap_or_else(|| detect_project_type(&resolved.repo_root));
165
166 let (project_type, output_path, content) = if opts.interactive {
168 if !is_tty() {
169 anyhow::bail!("Interactive mode requires a TTY terminal");
170 }
171
172 let prompter = wizard::DialoguerPrompter;
173 let wizard_result = wizard::run_init_wizard(
174 &prompter,
175 detected_type_to_hint(detected_type),
176 &opts.output_path,
177 )
178 .context("interactive wizard failed")?;
179
180 let project_type = hint_to_detected(wizard_result.project_type);
181 let output_path = wizard_result
182 .output_path
183 .unwrap_or_else(|| opts.output_path.clone());
184
185 let content = generate_agents_md_with_hints(
187 resolved,
188 project_type,
189 Some(&wizard_result.config_hints),
190 )?;
191
192 if wizard_result.confirm_write {
194 println!("\n{}", "─".repeat(60));
195 println!(
196 "{}",
197 colored::Colorize::bold("Preview of generated AGENTS.md:")
198 );
199 println!("{}", "─".repeat(60));
200 println!("{}", content);
201 println!("{}", "─".repeat(60));
202
203 let proceed = prompter
204 .confirm("Write this AGENTS.md?", true)
205 .context("failed to get confirmation")?;
206
207 if !proceed {
208 anyhow::bail!("AGENTS.md creation cancelled by user");
209 }
210 }
211
212 (project_type, output_path, content)
213 } else {
214 let content = generate_agents_md(resolved, detected_type)?;
216 (detected_type, opts.output_path.clone(), content)
217 };
218
219 if let Some(parent) = output_path.parent() {
221 fs::create_dir_all(parent)
222 .with_context(|| format!("create directory {}", parent.display()))?;
223 }
224 fsutil::write_atomic(&output_path, content.as_bytes())
225 .with_context(|| format!("write AGENTS.md {}", output_path.display()))?;
226
227 Ok(InitReport {
228 status: FileInitStatus::Created,
229 detected_project_type: project_type,
230 output_path,
231 })
232}
233
234pub fn run_context_update(
236 _resolved: &config::Resolved,
237 opts: ContextUpdateOptions,
238) -> Result<UpdateReport> {
239 if !opts.output_path.exists() {
241 anyhow::bail!(
242 "AGENTS.md does not exist at {}. Run `ralph context init` first.",
243 opts.output_path.display()
244 );
245 }
246
247 let existing_content =
249 fs::read_to_string(&opts.output_path).context("read existing AGENTS.md")?;
250
251 let existing_doc = merge::parse_markdown_document(&existing_content);
253 let existing_sections = existing_doc
254 .section_titles()
255 .into_iter()
256 .map(String::from)
257 .collect::<Vec<_>>();
258
259 let mut updates: Vec<(String, String)> = Vec::new();
260
261 if opts.interactive {
263 if !is_tty() {
264 anyhow::bail!("Interactive mode requires a TTY terminal");
265 }
266
267 let prompter = wizard::DialoguerPrompter;
268 updates = wizard::run_update_wizard(&prompter, &existing_sections, &existing_content)
269 .context("interactive wizard failed")?;
270 }
271 else if let Some(file_path) = &opts.file {
273 let new_content = fs::read_to_string(file_path).context("read update file")?;
274 let parsed = parse_markdown_sections(&new_content);
275
276 for (section_name, section_content) in parsed {
277 if opts.sections.is_empty() || opts.sections.contains(§ion_name) {
278 updates.push((section_name, section_content));
279 }
280 }
281 }
282 else {
284 anyhow::bail!(
285 "No update source specified. Use --interactive, --file, or specify sections with content."
286 );
287 }
288
289 if updates.is_empty() {
291 return Ok(UpdateReport {
292 sections_updated: Vec::new(),
293 dry_run: opts.dry_run,
294 });
295 }
296
297 let (merged_doc, sections_updated) = merge::merge_section_updates(&existing_doc, &updates);
299
300 if opts.dry_run {
302 println!("\n{}", "─".repeat(60));
303 println!(
304 "{}",
305 colored::Colorize::bold("Dry run - changes that would be made:")
306 );
307 println!("{}", "─".repeat(60));
308 for section in §ions_updated {
309 println!(" • Update section: {}", section);
310 }
311 println!("{}", "─".repeat(60));
312 return Ok(UpdateReport {
313 sections_updated,
314 dry_run: true,
315 });
316 }
317
318 let merged_content = merged_doc.to_content();
320 fsutil::write_atomic(&opts.output_path, merged_content.as_bytes())
321 .with_context(|| format!("write AGENTS.md {}", opts.output_path.display()))?;
322
323 Ok(UpdateReport {
324 sections_updated,
325 dry_run: false,
326 })
327}
328
329pub fn run_context_validate(
331 _resolved: &config::Resolved,
332 opts: ContextValidateOptions,
333) -> Result<ValidateReport> {
334 if !opts.path.exists() {
336 return Ok(ValidateReport {
337 valid: false,
338 missing_sections: REQUIRED_SECTIONS.iter().map(|s| s.to_string()).collect(),
339 outdated_sections: Vec::new(),
340 });
341 }
342
343 let content = fs::read_to_string(&opts.path).context("read AGENTS.md")?;
345
346 let sections = extract_section_titles(&content);
348 let section_set: HashSet<_> = sections.iter().map(|s| s.as_str()).collect();
349
350 let missing_sections: Vec<String> = REQUIRED_SECTIONS
352 .iter()
353 .filter(|s| !section_set.contains(**s))
354 .map(|s| s.to_string())
355 .collect();
356
357 let missing_recommended: Vec<String> = if opts.strict {
359 RECOMMENDED_SECTIONS
360 .iter()
361 .filter(|s| !section_set.contains(**s))
362 .map(|s| s.to_string())
363 .collect()
364 } else {
365 Vec::new()
366 };
367
368 let outdated_sections = Vec::new();
370
371 let valid = missing_sections.is_empty() && (missing_recommended.is_empty() || !opts.strict);
372
373 Ok(ValidateReport {
374 valid,
375 missing_sections: if opts.strict {
376 missing_recommended
377 } else {
378 missing_sections
379 },
380 outdated_sections,
381 })
382}
383
384fn hint_to_detected(hint: ProjectTypeHint) -> DetectedProjectType {
386 match hint {
387 ProjectTypeHint::Rust => DetectedProjectType::Rust,
388 ProjectTypeHint::Python => DetectedProjectType::Python,
389 ProjectTypeHint::TypeScript => DetectedProjectType::TypeScript,
390 ProjectTypeHint::Go => DetectedProjectType::Go,
391 ProjectTypeHint::Generic => DetectedProjectType::Generic,
392 }
393}
394
395fn detected_type_to_hint(detected: DetectedProjectType) -> ProjectTypeHint {
397 match detected {
398 DetectedProjectType::Rust => ProjectTypeHint::Rust,
399 DetectedProjectType::Python => ProjectTypeHint::Python,
400 DetectedProjectType::TypeScript => ProjectTypeHint::TypeScript,
401 DetectedProjectType::Go => ProjectTypeHint::Go,
402 DetectedProjectType::Generic => ProjectTypeHint::Generic,
403 }
404}
405
406fn is_tty() -> bool {
408 std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
409}
410
411fn detect_project_type(repo_root: &Path) -> DetectedProjectType {
413 if repo_root.join("Cargo.toml").exists() {
415 return DetectedProjectType::Rust;
416 }
417 if repo_root.join("pyproject.toml").exists()
419 || repo_root.join("setup.py").exists()
420 || repo_root.join("requirements.txt").exists()
421 {
422 return DetectedProjectType::Python;
423 }
424 if repo_root.join("package.json").exists() {
426 return DetectedProjectType::TypeScript;
427 }
428 if repo_root.join("go.mod").exists() {
430 return DetectedProjectType::Go;
431 }
432 DetectedProjectType::Generic
433}
434
435fn generate_agents_md(
437 resolved: &config::Resolved,
438 project_type: DetectedProjectType,
439) -> Result<String> {
440 generate_agents_md_with_hints(resolved, project_type, None)
441}
442
443fn generate_agents_md_with_hints(
445 resolved: &config::Resolved,
446 project_type: DetectedProjectType,
447 hints: Option<&wizard::ConfigHints>,
448) -> Result<String> {
449 let template = match project_type {
450 DetectedProjectType::Rust => TEMPLATE_RUST,
451 DetectedProjectType::Python => TEMPLATE_PYTHON,
452 DetectedProjectType::TypeScript => TEMPLATE_TYPESCRIPT,
453 DetectedProjectType::Go => TEMPLATE_GO,
454 DetectedProjectType::Generic => TEMPLATE_GENERIC,
455 };
456
457 let repo_map = build_repository_map(resolved)?;
459
460 let project_name = resolved
462 .repo_root
463 .file_name()
464 .and_then(|n| n.to_str())
465 .unwrap_or("Project")
466 .to_string();
467
468 let id_prefix = resolved.id_prefix.clone();
470
471 let project_description = hints
473 .and_then(|h| h.project_description.as_deref())
474 .unwrap_or("Add a brief description of your project here.");
475 let ci_command = hints.map(|h| h.ci_command.as_str()).unwrap_or("make ci");
476 let build_command = hints
477 .map(|h| h.build_command.as_str())
478 .unwrap_or("make build");
479 let test_command = hints
480 .map(|h| h.test_command.as_str())
481 .unwrap_or("make test");
482 let lint_command = hints
483 .map(|h| h.lint_command.as_str())
484 .unwrap_or("make lint");
485 let format_command = hints
486 .map(|h| h.format_command.as_str())
487 .unwrap_or("make format");
488
489 let content = template
491 .replace("{project_name}", &project_name)
492 .replace("{project_description}", project_description)
493 .replace("{repository_map}", &repo_map)
494 .replace("{ci_command}", ci_command)
495 .replace("{build_command}", build_command)
496 .replace("{test_command}", test_command)
497 .replace("{lint_command}", lint_command)
498 .replace("{format_command}", format_command)
499 .replace(
500 "{package_name}",
501 &project_name.to_lowercase().replace(" ", "-"),
502 )
503 .replace(
504 "{module_name}",
505 &project_name.to_lowercase().replace(" ", "_"),
506 )
507 .replace("{id_prefix}", &id_prefix)
508 .replace("{version}", env!("CARGO_PKG_VERSION"))
509 .replace("{timestamp}", &format_rfc3339_now())
510 .replace("{template_version}", TEMPLATE_VERSION);
511
512 Ok(content)
513}
514
515fn build_repository_map(resolved: &config::Resolved) -> Result<String> {
517 let mut entries = Vec::new();
518
519 let dirs_to_check = [
521 ("src", "Source code"),
522 ("lib", "Library code"),
523 ("bin", "Binary/executable code"),
524 ("tests", "Tests"),
525 ("docs", "Documentation"),
526 ("crates", "Rust workspace crates"),
527 ("packages", "Package subdirectories"),
528 ("scripts", "Utility scripts"),
529 (".ralph", "Ralph runtime state (queue, config)"),
530 ];
531
532 for (dir, desc) in &dirs_to_check {
533 if resolved.repo_root.join(dir).exists() {
534 entries.push(format!("- `{}/`: {}", dir, desc));
535 }
536 }
537
538 let files_to_check = [
540 ("README.md", "Project overview"),
541 ("Makefile", "Build automation"),
542 ("Cargo.toml", "Rust package manifest"),
543 ("pyproject.toml", "Python package manifest"),
544 ("package.json", "Node.js package manifest"),
545 ("go.mod", "Go module definition"),
546 ];
547
548 for (file, desc) in &files_to_check {
549 if resolved.repo_root.join(file).exists() {
550 entries.push(format!("- `{}`: {}", file, desc));
551 }
552 }
553
554 if entries.is_empty() {
555 entries.push("- Add your repository structure here".to_string());
556 }
557
558 Ok(entries.join("\n"))
559}
560
561fn parse_markdown_sections(content: &str) -> Vec<(String, String)> {
563 let mut sections = Vec::new();
564 let mut current_title = String::new();
565 let mut current_content = Vec::new();
566
567 for line in content.lines() {
568 if let Some(stripped) = line.strip_prefix("## ") {
569 if !current_title.is_empty() {
571 sections.push((current_title, current_content.join("\n")));
572 }
573 current_title = stripped.trim().to_string();
575 current_content = Vec::new();
576 } else if !current_title.is_empty() {
577 current_content.push(line.to_string());
578 }
579 }
580
581 if !current_title.is_empty() {
583 sections.push((current_title, current_content.join("\n")));
584 }
585
586 sections
587}
588
589fn extract_section_titles(content: &str) -> Vec<String> {
591 content
592 .lines()
593 .filter_map(|line| line.strip_prefix("## ").map(|s| s.trim().to_string()))
594 .collect()
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use tempfile::TempDir;
601
602 fn create_test_resolved(dir: &TempDir) -> config::Resolved {
603 let repo_root = dir.path().to_path_buf();
604 config::Resolved {
605 config: crate::contracts::Config::default(),
606 queue_path: repo_root.join(".ralph/queue.json"),
607 done_path: repo_root.join(".ralph/done.json"),
608 id_prefix: "RQ".to_string(),
609 id_width: 4,
610 global_config_path: None,
611 project_config_path: Some(repo_root.join(".ralph/config.json")),
612 repo_root,
613 }
614 }
615
616 #[test]
617 fn detect_project_type_finds_rust() {
618 let dir = TempDir::new().unwrap();
619 fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
620 assert_eq!(detect_project_type(dir.path()), DetectedProjectType::Rust);
621 }
622
623 #[test]
624 fn detect_project_type_finds_python() {
625 let dir = TempDir::new().unwrap();
626 fs::write(dir.path().join("pyproject.toml"), "").unwrap();
627 assert_eq!(detect_project_type(dir.path()), DetectedProjectType::Python);
628 }
629
630 #[test]
631 fn detect_project_type_finds_typescript() {
632 let dir = TempDir::new().unwrap();
633 fs::write(dir.path().join("package.json"), "{}").unwrap();
634 assert_eq!(
635 detect_project_type(dir.path()),
636 DetectedProjectType::TypeScript
637 );
638 }
639
640 #[test]
641 fn detect_project_type_finds_go() {
642 let dir = TempDir::new().unwrap();
643 fs::write(dir.path().join("go.mod"), "module test").unwrap();
644 assert_eq!(detect_project_type(dir.path()), DetectedProjectType::Go);
645 }
646
647 #[test]
648 fn detect_project_type_defaults_to_generic() {
649 let dir = TempDir::new().unwrap();
650 assert_eq!(
651 detect_project_type(dir.path()),
652 DetectedProjectType::Generic
653 );
654 }
655
656 #[test]
657 fn init_creates_agents_md() -> Result<()> {
658 let dir = TempDir::new()?;
659 let resolved = create_test_resolved(&dir);
660 fs::create_dir_all(resolved.repo_root.join("src"))?;
661
662 let output_path = resolved.repo_root.join("AGENTS.md");
663 let report = run_context_init(
664 &resolved,
665 ContextInitOptions {
666 force: false,
667 project_type_hint: None,
668 output_path: output_path.clone(),
669 interactive: false,
670 },
671 )?;
672
673 assert_eq!(report.status, FileInitStatus::Created);
674 assert!(output_path.exists());
675
676 let content = fs::read_to_string(&output_path)?;
677 assert!(content.contains("# Repository Guidelines"));
678 assert!(content.contains("Non-Negotiables"));
679 assert!(content.contains("Repository Map"));
680
681 Ok(())
682 }
683
684 #[test]
685 fn init_skips_existing_without_force() -> Result<()> {
686 let dir = TempDir::new()?;
687 let resolved = create_test_resolved(&dir);
688
689 let output_path = resolved.repo_root.join("AGENTS.md");
690 fs::write(&output_path, "existing content")?;
691
692 let report = run_context_init(
693 &resolved,
694 ContextInitOptions {
695 force: false,
696 project_type_hint: None,
697 output_path: output_path.clone(),
698 interactive: false,
699 },
700 )?;
701
702 assert_eq!(report.status, FileInitStatus::Valid);
703 let content = fs::read_to_string(&output_path)?;
704 assert_eq!(content, "existing content");
705
706 Ok(())
707 }
708
709 #[test]
710 fn init_overwrites_with_force() -> Result<()> {
711 let dir = TempDir::new()?;
712 let resolved = create_test_resolved(&dir);
713
714 let output_path = resolved.repo_root.join("AGENTS.md");
715 fs::write(&output_path, "existing content")?;
716
717 let report = run_context_init(
718 &resolved,
719 ContextInitOptions {
720 force: true,
721 project_type_hint: None,
722 output_path: output_path.clone(),
723 interactive: false,
724 },
725 )?;
726
727 assert_eq!(report.status, FileInitStatus::Created);
728 let content = fs::read_to_string(&output_path)?;
729 assert!(content.contains("# Repository Guidelines"));
730
731 Ok(())
732 }
733
734 #[test]
735 fn validate_fails_when_file_missing() -> Result<()> {
736 let dir = TempDir::new()?;
737 let resolved = create_test_resolved(&dir);
738
739 let report = run_context_validate(
740 &resolved,
741 ContextValidateOptions {
742 strict: false,
743 path: resolved.repo_root.join("AGENTS.md"),
744 },
745 )?;
746
747 assert!(!report.valid);
748 assert!(!report.missing_sections.is_empty());
749
750 Ok(())
751 }
752
753 #[test]
754 fn validate_passes_for_valid_file() -> Result<()> {
755 let dir = TempDir::new()?;
756 let resolved = create_test_resolved(&dir);
757
758 let content = r#"# Repository Guidelines
760
761Test project.
762
763## Non-Negotiables
764
765Some rules.
766
767## Repository Map
768
769- `src/`: Source code
770
771## Build, Test, and CI
772
773Make targets.
774"#;
775 fs::write(resolved.repo_root.join("AGENTS.md"), content)?;
776
777 let report = run_context_validate(
778 &resolved,
779 ContextValidateOptions {
780 strict: false,
781 path: resolved.repo_root.join("AGENTS.md"),
782 },
783 )?;
784
785 assert!(report.valid);
786 assert!(report.missing_sections.is_empty());
787
788 Ok(())
789 }
790
791 #[test]
792 fn validate_strict_fails_for_missing_recommended() -> Result<()> {
793 let dir = TempDir::new()?;
794 let resolved = create_test_resolved(&dir);
795
796 let content = r#"# Repository Guidelines
798
799Test project.
800
801## Non-Negotiables
802
803Some rules.
804
805## Repository Map
806
807- `src/`: Source code
808
809## Build, Test, and CI
810
811Make targets.
812"#;
813 fs::write(resolved.repo_root.join("AGENTS.md"), content)?;
814
815 let report = run_context_validate(
816 &resolved,
817 ContextValidateOptions {
818 strict: true,
819 path: resolved.repo_root.join("AGENTS.md"),
820 },
821 )?;
822
823 assert!(!report.valid);
825 assert!(!report.missing_sections.is_empty());
826
827 Ok(())
828 }
829
830 #[test]
831 fn extract_section_titles_finds_all_sections() {
832 let content = r#"# Title
833
834## Section One
835
836Content one.
837
838## Section Two
839
840Content two.
841
842### Subsection
843
844More content.
845"#;
846 let titles = extract_section_titles(content);
847 assert_eq!(titles, vec!["Section One", "Section Two"]);
848 }
849
850 #[test]
851 fn parse_markdown_sections_extracts_content() {
852 let content = r#"# Title
853
854## Section One
855
856Content one.
857
858More content.
859
860## Section Two
861
862Content two.
863"#;
864 let sections = parse_markdown_sections(content);
865 assert_eq!(sections.len(), 2);
866 assert_eq!(sections[0].0, "Section One");
867 assert!(sections[0].1.contains("Content one."));
868 assert_eq!(sections[1].0, "Section Two");
869 }
870
871 #[test]
872 fn update_fails_when_file_missing() {
873 let dir = TempDir::new().unwrap();
874 let resolved = create_test_resolved(&dir);
875
876 let result = run_context_update(
877 &resolved,
878 ContextUpdateOptions {
879 sections: vec!["troubleshooting".to_string()],
880 file: None,
881 interactive: false,
882 dry_run: false,
883 output_path: resolved.repo_root.join("AGENTS.md"),
884 },
885 );
886
887 assert!(result.is_err());
888 }
889
890 #[test]
891 fn update_returns_sections_updated() -> Result<()> {
892 let dir = TempDir::new()?;
893 let resolved = create_test_resolved(&dir);
894
895 fs::write(
897 resolved.repo_root.join("AGENTS.md"),
898 "# Repository Guidelines\n\n## Non-Negotiables\n\nRules.\n",
899 )?;
900
901 fs::write(
903 resolved.repo_root.join("update.md"),
904 "## Non-Negotiables\n\nAdditional rules.\n",
905 )?;
906
907 let report = run_context_update(
908 &resolved,
909 ContextUpdateOptions {
910 sections: vec!["Non-Negotiables".to_string()],
911 file: Some(resolved.repo_root.join("update.md")),
912 interactive: false,
913 dry_run: true,
914 output_path: resolved.repo_root.join("AGENTS.md"),
915 },
916 )?;
917
918 assert!(report.dry_run);
919 assert!(
920 report
921 .sections_updated
922 .contains(&"Non-Negotiables".to_string())
923 );
924
925 Ok(())
926 }
927}