Skip to main content

ralph/commands/
context.rs

1//! Project context (AGENTS.md) generation and management.
2//!
3//! Responsibilities:
4//! - Generate initial AGENTS.md from project type detection.
5//! - Update AGENTS.md with new learnings.
6//! - Validate AGENTS.md against project structure.
7//!
8//! Not handled here:
9//! - CLI argument parsing (see `cli::context`).
10//! - Interactive prompts (see `wizard` module).
11//!
12//! Invariants/assumptions:
13//! - Templates are embedded at compile time.
14//! - Project type detection uses simple file-based heuristics.
15//! - AGENTS.md updates preserve manual edits (section-based merging).
16
17use 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/// Detected project type
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum DetectedProjectType {
57    Rust,
58    Python,
59    TypeScript,
60    Go,
61    Generic,
62}
63
64/// Format current time as RFC3339 using the `time` crate
65fn format_rfc3339_now() -> String {
66    let now = time::OffsetDateTime::now_utc();
67    // Format as RFC3339: 2026-01-28T12:34:56Z
68    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/// Status of file initialization
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum FileInitStatus {
94    Created,
95    Valid,
96}
97
98/// Options for context init command
99pub 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
106/// Options for context update command
107pub 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
115/// Options for context validate command
116pub struct ContextValidateOptions {
117    pub strict: bool,
118    pub path: std::path::PathBuf,
119}
120
121/// Report from init command
122pub struct InitReport {
123    pub status: FileInitStatus,
124    pub detected_project_type: DetectedProjectType,
125    pub output_path: std::path::PathBuf,
126}
127
128/// Report from update command
129pub struct UpdateReport {
130    pub sections_updated: Vec<String>,
131    pub dry_run: bool,
132}
133
134/// Report from validate command
135pub struct ValidateReport {
136    pub valid: bool,
137    pub missing_sections: Vec<String>,
138    pub outdated_sections: Vec<String>,
139}
140
141/// Run the context init command
142pub fn run_context_init(
143    resolved: &config::Resolved,
144    opts: ContextInitOptions,
145) -> Result<InitReport> {
146    // Check if file exists and we're not forcing (only in non-interactive mode)
147    // In interactive mode, we let the user decide
148    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    // Determine project type (for non-interactive or as default for interactive)
161    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    // Interactive mode: run wizard
167    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        // Generate content with hints
186        let content = generate_agents_md_with_hints(
187            resolved,
188            project_type,
189            Some(&wizard_result.config_hints),
190        )?;
191
192        // Preview and confirm if requested
193        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        // Non-interactive mode
215        let content = generate_agents_md(resolved, detected_type)?;
216        (detected_type, opts.output_path.clone(), content)
217    };
218
219    // Write file
220    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
234/// Run the context update command
235pub fn run_context_update(
236    _resolved: &config::Resolved,
237    opts: ContextUpdateOptions,
238) -> Result<UpdateReport> {
239    // Check if file exists
240    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    // Read existing content
248    let existing_content =
249        fs::read_to_string(&opts.output_path).context("read existing AGENTS.md")?;
250
251    // Parse existing document
252    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    // Handle interactive mode
262    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    // Handle file-based update
272    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(&section_name) {
278                updates.push((section_name, section_content));
279            }
280        }
281    }
282    // Handle section-specific updates (non-interactive, no file)
283    else {
284        anyhow::bail!(
285            "No update source specified. Use --interactive, --file, or specify sections with content."
286        );
287    }
288
289    // If no updates, return early
290    if updates.is_empty() {
291        return Ok(UpdateReport {
292            sections_updated: Vec::new(),
293            dry_run: opts.dry_run,
294        });
295    }
296
297    // Merge updates into existing document
298    let (merged_doc, sections_updated) = merge::merge_section_updates(&existing_doc, &updates);
299
300    // If dry run, preview changes without writing
301    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 &sections_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    // Write merged content back
319    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
329/// Run the context validate command
330pub fn run_context_validate(
331    _resolved: &config::Resolved,
332    opts: ContextValidateOptions,
333) -> Result<ValidateReport> {
334    // Check if file exists
335    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    // Read content
344    let content = fs::read_to_string(&opts.path).context("read AGENTS.md")?;
345
346    // Parse sections
347    let sections = extract_section_titles(&content);
348    let section_set: HashSet<_> = sections.iter().map(|s| s.as_str()).collect();
349
350    // Check for missing required sections
351    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    // In strict mode, also check recommended sections
358    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    // Check for outdated template version (if present in file)
369    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
384/// Convert CLI hint to detected type
385fn 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
395/// Convert detected type to CLI hint
396fn 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
406/// Check if stdin and stdout are both TTYs
407fn is_tty() -> bool {
408    std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
409}
410
411/// Detect project type based on files in repo root
412fn detect_project_type(repo_root: &Path) -> DetectedProjectType {
413    // Check for Rust
414    if repo_root.join("Cargo.toml").exists() {
415        return DetectedProjectType::Rust;
416    }
417    // Check for Python
418    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    // Check for TypeScript/JavaScript
425    if repo_root.join("package.json").exists() {
426        return DetectedProjectType::TypeScript;
427    }
428    // Check for Go
429    if repo_root.join("go.mod").exists() {
430        return DetectedProjectType::Go;
431    }
432    DetectedProjectType::Generic
433}
434
435/// Generate AGENTS.md content for the given project type
436fn 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
443/// Generate AGENTS.md content with optional config hints
444fn 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    // Build repository map
458    let repo_map = build_repository_map(resolved)?;
459
460    // Get project name from directory name
461    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    // Get id_prefix from config
469    let id_prefix = resolved.id_prefix.clone();
470
471    // Use hints if provided, otherwise defaults
472    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    // Replace placeholders
490    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
515/// Build a repository map string based on detected structure
516fn build_repository_map(resolved: &config::Resolved) -> Result<String> {
517    let mut entries = Vec::new();
518
519    // Check for common directories
520    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    // Check for key files
539    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
561/// Parse markdown content into sections
562fn 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            // Save previous section if exists
570            if !current_title.is_empty() {
571                sections.push((current_title, current_content.join("\n")));
572            }
573            // Start new section
574            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    // Save last section
582    if !current_title.is_empty() {
583        sections.push((current_title, current_content.join("\n")));
584    }
585
586    sections
587}
588
589/// Extract section titles from markdown content
590fn 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        // Create a valid AGENTS.md
759        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        // Create an AGENTS.md missing recommended sections
797        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        // Should fail in strict mode due to missing recommended sections
824        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        // Create initial AGENTS.md with a section to update
896        fs::write(
897            resolved.repo_root.join("AGENTS.md"),
898            "# Repository Guidelines\n\n## Non-Negotiables\n\nRules.\n",
899        )?;
900
901        // Create an update file to use for the test
902        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}