Skip to main content

ralph_core/
preflight.rs

1//! Preflight checks for validating environment and configuration before running.
2
3use crate::config::ConfigWarning;
4use crate::{RalphConfig, git_ops};
5use async_trait::async_trait;
6use serde::Serialize;
7use std::collections::HashMap;
8use std::env;
9use std::ffi::OsString;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use std::time::Duration;
13
14/// Status of a preflight check.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "lowercase")]
17pub enum CheckStatus {
18    Pass,
19    Warn,
20    Fail,
21}
22
23/// Result of a single preflight check.
24#[derive(Debug, Clone, Serialize)]
25pub struct CheckResult {
26    pub name: String,
27    pub label: String,
28    pub status: CheckStatus,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub message: Option<String>,
31}
32
33impl CheckResult {
34    pub fn pass(name: &str, label: impl Into<String>) -> Self {
35        Self {
36            name: name.to_string(),
37            label: label.into(),
38            status: CheckStatus::Pass,
39            message: None,
40        }
41    }
42
43    pub fn warn(name: &str, label: impl Into<String>, message: impl Into<String>) -> Self {
44        Self {
45            name: name.to_string(),
46            label: label.into(),
47            status: CheckStatus::Warn,
48            message: Some(message.into()),
49        }
50    }
51
52    pub fn fail(name: &str, label: impl Into<String>, message: impl Into<String>) -> Self {
53        Self {
54            name: name.to_string(),
55            label: label.into(),
56            status: CheckStatus::Fail,
57            message: Some(message.into()),
58        }
59    }
60}
61
62/// A single preflight check.
63#[async_trait]
64pub trait PreflightCheck: Send + Sync {
65    fn name(&self) -> &'static str;
66    async fn run(&self, config: &RalphConfig) -> CheckResult;
67}
68
69/// Aggregated preflight report.
70#[derive(Debug, Clone, Serialize)]
71pub struct PreflightReport {
72    pub passed: bool,
73    pub warnings: usize,
74    pub failures: usize,
75    pub checks: Vec<CheckResult>,
76}
77
78impl PreflightReport {
79    fn from_results(checks: Vec<CheckResult>) -> Self {
80        let warnings = checks
81            .iter()
82            .filter(|check| check.status == CheckStatus::Warn)
83            .count();
84        let failures = checks
85            .iter()
86            .filter(|check| check.status == CheckStatus::Fail)
87            .count();
88        let passed = failures == 0;
89
90        Self {
91            passed,
92            warnings,
93            failures,
94            checks,
95        }
96    }
97}
98
99/// Runs a set of preflight checks.
100pub struct PreflightRunner {
101    checks: Vec<Box<dyn PreflightCheck>>,
102}
103
104impl PreflightRunner {
105    pub fn default_checks() -> Self {
106        Self {
107            checks: vec![
108                Box::new(ConfigValidCheck),
109                Box::new(HooksValidationCheck),
110                Box::new(BackendAvailableCheck),
111                Box::new(TelegramTokenCheck),
112                Box::new(GitCleanCheck),
113                Box::new(PathsExistCheck),
114                Box::new(ToolsInPathCheck::default()),
115                Box::new(SpecCompletenessCheck),
116            ],
117        }
118    }
119
120    pub fn check_names(&self) -> Vec<&str> {
121        self.checks.iter().map(|check| check.name()).collect()
122    }
123
124    pub async fn run_all(&self, config: &RalphConfig) -> PreflightReport {
125        Self::run_checks(self.checks.iter(), config).await
126    }
127
128    pub async fn run_selected(&self, config: &RalphConfig, names: &[String]) -> PreflightReport {
129        let requested: Vec<String> = names.iter().map(|name| name.to_lowercase()).collect();
130        let checks = self
131            .checks
132            .iter()
133            .filter(|check| requested.contains(&check.name().to_lowercase()));
134
135        Self::run_checks(checks, config).await
136    }
137
138    async fn run_checks<'a, I>(checks: I, config: &RalphConfig) -> PreflightReport
139    where
140        I: IntoIterator<Item = &'a Box<dyn PreflightCheck>>,
141    {
142        let mut results = Vec::new();
143        for check in checks {
144            results.push(check.run(config).await);
145        }
146
147        PreflightReport::from_results(results)
148    }
149}
150
151struct ConfigValidCheck;
152
153#[async_trait]
154impl PreflightCheck for ConfigValidCheck {
155    fn name(&self) -> &'static str {
156        "config"
157    }
158
159    async fn run(&self, config: &RalphConfig) -> CheckResult {
160        match config.validate() {
161            Ok(warnings) if warnings.is_empty() => {
162                CheckResult::pass(self.name(), "Configuration valid")
163            }
164            Ok(warnings) => {
165                let warning_count = warnings.len();
166                let details = format_config_warnings(&warnings);
167                CheckResult::warn(
168                    self.name(),
169                    format!("Configuration valid ({warning_count} warning(s))"),
170                    details,
171                )
172            }
173            Err(err) => CheckResult::fail(self.name(), "Configuration invalid", format!("{err}")),
174        }
175    }
176}
177
178struct HooksValidationCheck;
179
180#[async_trait]
181impl PreflightCheck for HooksValidationCheck {
182    fn name(&self) -> &'static str {
183        "hooks"
184    }
185
186    async fn run(&self, config: &RalphConfig) -> CheckResult {
187        if !config.hooks.enabled {
188            return CheckResult::pass(self.name(), "Hooks disabled (skipping)");
189        }
190
191        let mut diagnostics = Vec::new();
192        validate_hook_duplicate_names(config, &mut diagnostics);
193        validate_hook_command_resolvability(config, &mut diagnostics);
194
195        if diagnostics.is_empty() {
196            CheckResult::pass(
197                self.name(),
198                format!(
199                    "Hooks validation passed ({} hook(s))",
200                    count_configured_hooks(config)
201                ),
202            )
203        } else {
204            CheckResult::fail(
205                self.name(),
206                format!("Hooks validation failed ({} issue(s))", diagnostics.len()),
207                diagnostics.join("\n"),
208            )
209        }
210    }
211}
212
213fn count_configured_hooks(config: &RalphConfig) -> usize {
214    config.hooks.events.values().map(Vec::len).sum()
215}
216
217fn validate_hook_duplicate_names(config: &RalphConfig, diagnostics: &mut Vec<String>) {
218    let mut phase_events: Vec<_> = config.hooks.events.iter().collect();
219    phase_events.sort_by_key(|(phase_event, _)| phase_event.as_str());
220
221    for (phase_event, hooks) in phase_events {
222        let mut seen: HashMap<&str, usize> = HashMap::new();
223
224        for (index, hook) in hooks.iter().enumerate() {
225            let name = hook.name.trim();
226            if name.is_empty() {
227                continue;
228            }
229
230            if let Some(first_index) = seen.insert(name, index) {
231                diagnostics.push(format!(
232                    "hooks.events.{}[{}].name: duplicate hook name '{}' (first defined at index {}). Hook names must be unique per phase-event.",
233                    phase_event.as_str(),
234                    index,
235                    name,
236                    first_index
237                ));
238            }
239        }
240    }
241}
242
243fn validate_hook_command_resolvability(config: &RalphConfig, diagnostics: &mut Vec<String>) {
244    let mut phase_events: Vec<_> = config.hooks.events.iter().collect();
245    phase_events.sort_by_key(|(phase_event, _)| phase_event.as_str());
246
247    for (phase_event, hooks) in phase_events {
248        for (index, hook) in hooks.iter().enumerate() {
249            let Some(command) = hook
250                .command
251                .first()
252                .map(|entry| entry.trim())
253                .filter(|entry| !entry.is_empty())
254            else {
255                continue;
256            };
257
258            let cwd = resolve_hook_cwd(&config.core.workspace_root, hook.cwd.as_deref());
259            let path_override = hook_path_override(&hook.env);
260
261            if let Err(message) = resolve_hook_command(command, &cwd, path_override) {
262                diagnostics.push(format!(
263                    "hooks.events.{}[{}].command '{}': {}\nFix: ensure command exists and is executable, or invoke the script through an interpreter (for example: ['bash', 'script.sh']).",
264                    phase_event.as_str(),
265                    index,
266                    command,
267                    message
268                ));
269            }
270        }
271    }
272}
273
274fn hook_path_override(env_map: &HashMap<String, String>) -> Option<&str> {
275    env_map
276        .get("PATH")
277        .or_else(|| env_map.get("Path"))
278        .map(String::as_str)
279}
280
281fn resolve_hook_cwd(workspace_root: &Path, hook_cwd: Option<&Path>) -> PathBuf {
282    match hook_cwd {
283        Some(path) if path.is_absolute() => path.to_path_buf(),
284        Some(path) => workspace_root.join(path),
285        None => workspace_root.to_path_buf(),
286    }
287}
288
289fn resolve_hook_command(
290    command: &str,
291    cwd: &Path,
292    path_override: Option<&str>,
293) -> std::result::Result<PathBuf, String> {
294    let command_path = Path::new(command);
295    if command_path.is_absolute() || command_path.components().count() > 1 {
296        let resolved = if command_path.is_absolute() {
297            command_path.to_path_buf()
298        } else {
299            cwd.join(command_path)
300        };
301
302        if !resolved.exists() {
303            return Err(format!(
304                "resolves to '{}' but the file does not exist.",
305                resolved.display()
306            ));
307        }
308
309        if !is_executable_file(&resolved) {
310            return Err(format!(
311                "resolves to '{}' but it is not executable.",
312                resolved.display()
313            ));
314        }
315
316        return Ok(resolved);
317    }
318
319    let path_value = path_override
320        .map(OsString::from)
321        .or_else(|| env::var_os("PATH"))
322        .ok_or_else(|| {
323            format!(
324                "PATH is not set while resolving command '{}'. Set PATH in the environment or hook env override.",
325                command
326            )
327        })?;
328
329    let extensions = executable_extensions();
330
331    for dir in env::split_paths(&path_value) {
332        for extension in &extensions {
333            let candidate = if extension.is_empty() {
334                dir.join(command)
335            } else {
336                dir.join(format!("{command}{}", extension.to_string_lossy()))
337            };
338
339            if is_executable_file(&candidate) {
340                return Ok(candidate);
341            }
342        }
343    }
344
345    let path_source = if path_override.is_some() {
346        "hook env PATH"
347    } else {
348        "process PATH"
349    };
350
351    Err(format!("was not found in {path_source}."))
352}
353
354fn is_executable_file(path: &Path) -> bool {
355    if !path.is_file() {
356        return false;
357    }
358
359    #[cfg(unix)]
360    {
361        use std::os::unix::fs::PermissionsExt;
362
363        std::fs::metadata(path)
364            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
365            .unwrap_or(false)
366    }
367
368    #[cfg(not(unix))]
369    {
370        true
371    }
372}
373
374struct BackendAvailableCheck;
375
376#[async_trait]
377impl PreflightCheck for BackendAvailableCheck {
378    fn name(&self) -> &'static str {
379        "backend"
380    }
381
382    async fn run(&self, config: &RalphConfig) -> CheckResult {
383        let backend = config.cli.backend.trim();
384        if backend.eq_ignore_ascii_case("auto") {
385            return check_auto_backend(self.name(), config);
386        }
387
388        check_named_backend(self.name(), config, backend)
389    }
390}
391
392struct TelegramTokenCheck;
393
394#[async_trait]
395impl PreflightCheck for TelegramTokenCheck {
396    fn name(&self) -> &'static str {
397        "telegram"
398    }
399
400    async fn run(&self, config: &RalphConfig) -> CheckResult {
401        if !config.robot.enabled {
402            return CheckResult::pass(self.name(), "RObot disabled (skipping)");
403        }
404
405        let Some(token) = config.robot.resolve_bot_token() else {
406            return CheckResult::fail(
407                self.name(),
408                "Telegram token missing",
409                "Set RALPH_TELEGRAM_BOT_TOKEN or configure RObot.telegram.bot_token",
410            );
411        };
412
413        match telegram_get_me(&token).await {
414            Ok(info) => {
415                CheckResult::pass(self.name(), format!("Bot token valid (@{})", info.username))
416            }
417            Err(err) => CheckResult::fail(self.name(), "Telegram token invalid", format!("{err}")),
418        }
419    }
420}
421
422struct GitCleanCheck;
423
424#[async_trait]
425impl PreflightCheck for GitCleanCheck {
426    fn name(&self) -> &'static str {
427        "git"
428    }
429
430    async fn run(&self, config: &RalphConfig) -> CheckResult {
431        let root = &config.core.workspace_root;
432        if !is_git_workspace(root) {
433            return CheckResult::pass(self.name(), "Not a git repository (skipping)");
434        }
435
436        let branch = match git_ops::get_current_branch(root) {
437            Ok(branch) => branch,
438            Err(err) => {
439                return CheckResult::fail(
440                    self.name(),
441                    "Git repository unavailable",
442                    format!("{err}"),
443                );
444            }
445        };
446
447        match git_ops::is_working_tree_clean(root) {
448            Ok(true) => CheckResult::pass(self.name(), format!("Working tree clean ({branch})")),
449            Ok(false) => CheckResult::warn(
450                self.name(),
451                "Working tree has uncommitted changes",
452                "Commit or stash changes before running for clean diffs",
453            ),
454            Err(err) => {
455                CheckResult::fail(self.name(), "Unable to read git status", format!("{err}"))
456            }
457        }
458    }
459}
460
461struct PathsExistCheck;
462
463#[async_trait]
464impl PreflightCheck for PathsExistCheck {
465    fn name(&self) -> &'static str {
466        "paths"
467    }
468
469    async fn run(&self, config: &RalphConfig) -> CheckResult {
470        let mut created = Vec::new();
471
472        let scratchpad_path = config.core.resolve_path(&config.core.scratchpad);
473        if let Some(parent) = scratchpad_path.parent()
474            && let Err(err) = ensure_directory(parent, &mut created)
475        {
476            return CheckResult::fail(
477                self.name(),
478                "Scratchpad path unavailable",
479                format!("{}", err),
480            );
481        }
482
483        let specs_path = config.core.resolve_path(&config.core.specs_dir);
484        if let Err(err) = ensure_directory(&specs_path, &mut created) {
485            return CheckResult::fail(
486                self.name(),
487                "Specs directory unavailable",
488                format!("{}", err),
489            );
490        }
491
492        if created.is_empty() {
493            CheckResult::pass(self.name(), "Workspace paths accessible")
494        } else {
495            CheckResult::warn(
496                self.name(),
497                "Workspace paths created",
498                format!("Created: {}", created.join(", ")),
499            )
500        }
501    }
502}
503
504#[derive(Debug, Clone)]
505struct ToolsInPathCheck {
506    required: Vec<String>,
507    optional: Vec<String>,
508}
509
510impl ToolsInPathCheck {
511    #[cfg(test)]
512    fn new(required: Vec<String>) -> Self {
513        Self {
514            required,
515            optional: Vec::new(),
516        }
517    }
518
519    fn new_with_optional(required: Vec<String>, optional: Vec<String>) -> Self {
520        Self { required, optional }
521    }
522}
523
524impl Default for ToolsInPathCheck {
525    fn default() -> Self {
526        Self::new_with_optional(vec!["git".to_string()], Vec::new())
527    }
528}
529
530#[async_trait]
531impl PreflightCheck for ToolsInPathCheck {
532    fn name(&self) -> &'static str {
533        "tools"
534    }
535
536    async fn run(&self, config: &RalphConfig) -> CheckResult {
537        if !is_git_workspace(&config.core.workspace_root) {
538            return CheckResult::pass(self.name(), "Not a git repository (skipping)");
539        }
540
541        let missing_required: Vec<String> = self
542            .required
543            .iter()
544            .filter(|tool| find_executable(tool).is_none())
545            .cloned()
546            .collect();
547
548        let missing_optional: Vec<String> = self
549            .optional
550            .iter()
551            .filter(|tool| find_executable(tool).is_none())
552            .cloned()
553            .collect();
554
555        if missing_required.is_empty() && missing_optional.is_empty() {
556            let mut tools = self.required.clone();
557            tools.extend(self.optional.clone());
558            CheckResult::pass(
559                self.name(),
560                format!("Required tools available ({})", tools.join(", ")),
561            )
562        } else if missing_required.is_empty() {
563            CheckResult::warn(
564                self.name(),
565                "Missing optional tools",
566                format!("Missing: {}", missing_optional.join(", ")),
567            )
568        } else {
569            let mut detail = format!("required: {}", missing_required.join(", "));
570            if !missing_optional.is_empty() {
571                detail.push_str(&format!("; optional: {}", missing_optional.join(", ")));
572            }
573            CheckResult::fail(
574                self.name(),
575                "Missing required tools",
576                format!("Missing {}", detail),
577            )
578        }
579    }
580}
581
582struct SpecCompletenessCheck;
583
584#[async_trait]
585impl PreflightCheck for SpecCompletenessCheck {
586    fn name(&self) -> &'static str {
587        "specs"
588    }
589
590    async fn run(&self, config: &RalphConfig) -> CheckResult {
591        let specs_dir = config.core.resolve_path(&config.core.specs_dir);
592
593        if !specs_dir.exists() {
594            return CheckResult::pass(self.name(), "No specs directory (skipping)");
595        }
596
597        let spec_files = match collect_spec_files(&specs_dir) {
598            Ok(files) => files,
599            Err(err) => {
600                return CheckResult::fail(
601                    self.name(),
602                    "Unable to read specs directory",
603                    format!("{err}"),
604                );
605            }
606        };
607
608        if spec_files.is_empty() {
609            return CheckResult::pass(self.name(), "No spec files found (skipping)");
610        }
611
612        let mut incomplete: Vec<String> = Vec::new();
613
614        for path in &spec_files {
615            let content = match std::fs::read_to_string(path) {
616                Ok(c) => c,
617                Err(err) => {
618                    incomplete.push(format!(
619                        "{}: unreadable ({})",
620                        path.file_name().unwrap_or_default().to_string_lossy(),
621                        err
622                    ));
623                    continue;
624                }
625            };
626
627            if let Some(reason) = check_spec_completeness(path, &content) {
628                incomplete.push(reason);
629            }
630        }
631
632        if incomplete.is_empty() {
633            CheckResult::pass(
634                self.name(),
635                format!(
636                    "{} spec(s) valid with acceptance criteria",
637                    spec_files.len()
638                ),
639            )
640        } else {
641            let total = spec_files.len();
642            CheckResult::warn(
643                self.name(),
644                format!(
645                    "{} of {} spec(s) missing acceptance criteria",
646                    incomplete.len(),
647                    total
648                ),
649                format!(
650                    "Specs should include Given/When/Then acceptance criteria.\n{}",
651                    incomplete.join("\n")
652                ),
653            )
654        }
655    }
656}
657
658/// Recursively collect all `.spec.md` files under a directory.
659fn collect_spec_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
660    let mut files = Vec::new();
661    collect_spec_files_recursive(dir, &mut files)?;
662    files.sort();
663    Ok(files)
664}
665
666fn collect_spec_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
667    for entry in std::fs::read_dir(dir)? {
668        let entry = entry?;
669        let path = entry.path();
670        if path.is_dir() {
671            collect_spec_files_recursive(&path, files)?;
672        } else if path
673            .file_name()
674            .and_then(|n| n.to_str())
675            .is_some_and(|n| n.ends_with(".spec.md"))
676        {
677            files.push(path);
678        }
679    }
680    Ok(())
681}
682
683/// Check whether a spec file has the required sections for Level 5 completeness.
684///
685/// Returns `None` if the spec is complete, or `Some(reason)` if incomplete.
686fn check_spec_completeness(path: &Path, content: &str) -> Option<String> {
687    let filename = path
688        .file_name()
689        .unwrap_or_default()
690        .to_string_lossy()
691        .to_string();
692
693    // Skip specs that are already marked as implemented — they passed review
694    let content_lower = content.to_lowercase();
695    if content_lower.contains("status: implemented") {
696        return None;
697    }
698
699    let has_acceptance = has_acceptance_criteria(content);
700
701    if !has_acceptance {
702        return Some(format!(
703            "{filename}: missing acceptance criteria (Given/When/Then)"
704        ));
705    }
706
707    None
708}
709
710/// Detect whether content contains Given/When/Then acceptance criteria.
711///
712/// Matches common spec patterns:
713/// - `**Given**` / `**When**` / `**Then**` (bold markdown)
714/// - `Given ` / `When ` / `Then ` at line start (plain text)
715/// - `- Given ` / `- When ` / `- Then ` (list items)
716fn has_acceptance_criteria(content: &str) -> bool {
717    let mut has_given = false;
718    let mut has_when = false;
719    let mut has_then = false;
720
721    for line in content.lines() {
722        let trimmed = line.trim();
723        let lower = trimmed.to_lowercase();
724
725        // Bold markdown: **Given**, **When**, **Then**
726        // Plain text at line start: Given, When, Then
727        // List items: - Given, - When, - Then
728        if lower.starts_with("**given**")
729            || lower.starts_with("given ")
730            || lower.starts_with("- given ")
731            || lower.starts_with("- **given**")
732        {
733            has_given = true;
734        }
735        if lower.starts_with("**when**")
736            || lower.starts_with("when ")
737            || lower.starts_with("- when ")
738            || lower.starts_with("- **when**")
739        {
740            has_when = true;
741        }
742        if lower.starts_with("**then**")
743            || lower.starts_with("then ")
744            || lower.starts_with("- then ")
745            || lower.starts_with("- **then**")
746        {
747            has_then = true;
748        }
749
750        if has_given && has_when && has_then {
751            return true;
752        }
753    }
754
755    // Require at least Given+Then (When is sometimes implicit)
756    has_given && has_then
757}
758
759/// A single acceptance criterion extracted from a spec file.
760#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
761pub struct AcceptanceCriterion {
762    /// The precondition (Given clause).
763    pub given: String,
764    /// The action or trigger (When clause). Optional because some specs omit it.
765    pub when: Option<String>,
766    /// The expected outcome (Then clause).
767    pub then: String,
768}
769
770/// Extract structured Given/When/Then acceptance criteria from spec content.
771///
772/// Parses the same patterns recognized by [`has_acceptance_criteria`] but returns
773/// structured triples instead of a boolean. Each contiguous Given[/When]/Then
774/// group produces one [`AcceptanceCriterion`].
775pub fn extract_acceptance_criteria(content: &str) -> Vec<AcceptanceCriterion> {
776    let mut criteria = Vec::new();
777    let mut current_given: Option<String> = None;
778    let mut current_when: Option<String> = None;
779
780    for line in content.lines() {
781        let trimmed = line.trim();
782        let lower = trimmed.to_lowercase();
783
784        if let Some(text) = match_clause(&lower, trimmed, "given") {
785            // Flush any previous incomplete criterion before starting a new Given
786            if let Some(given) = current_given.take() {
787                // Previous Given without Then — skip incomplete criterion
788                let _ = given;
789            }
790            current_given = Some(text);
791            current_when = None;
792        } else if let Some(text) = match_clause(&lower, trimmed, "when") {
793            current_when = Some(text);
794        } else if let Some(text) = match_clause(&lower, trimmed, "then") {
795            if let Some(given) = current_given.take() {
796                criteria.push(AcceptanceCriterion {
797                    given,
798                    when: current_when.take(),
799                    then: text,
800                });
801            }
802            // Reset for next criterion
803            current_when = None;
804        }
805    }
806
807    criteria
808}
809
810/// Match a Given/When/Then clause line and extract the text after the keyword.
811///
812/// Handles bold (`**Given**`), plain (`Given `), list (`- Given `), and bold-list
813/// (`- **Given**`) formats. Returns the text portion after the keyword, or `None`
814/// if the line doesn't match.
815fn match_clause(lower: &str, original: &str, keyword: &str) -> Option<String> {
816    let bold = format!("**{keyword}**");
817    let plain = format!("{keyword} ");
818    let list_plain = format!("- {keyword} ");
819    let list_bold = format!("- **{keyword}**");
820
821    // Determine the offset where the actual text starts
822    let text_start = if lower.starts_with(&bold) {
823        Some(bold.len())
824    } else if lower.starts_with(&list_bold) {
825        Some(list_bold.len())
826    } else if lower.starts_with(&list_plain) {
827        Some(list_plain.len())
828    } else if lower.starts_with(&plain) {
829        Some(plain.len())
830    } else {
831        None
832    };
833
834    text_start.map(|offset| original[offset..].trim().to_string())
835}
836
837/// Extract acceptance criteria from a spec file at the given path.
838///
839/// Reads the file, skips `status: implemented` specs, and returns structured
840/// criteria. Returns an empty vec if the file is unreadable or already implemented.
841pub fn extract_criteria_from_file(path: &Path) -> Vec<AcceptanceCriterion> {
842    let content = match std::fs::read_to_string(path) {
843        Ok(c) => c,
844        Err(_) => return Vec::new(),
845    };
846
847    // Skip implemented specs
848    if content.to_lowercase().contains("status: implemented") {
849        return Vec::new();
850    }
851
852    extract_acceptance_criteria(&content)
853}
854
855/// Extract acceptance criteria from all spec files in a directory.
856///
857/// Returns a vec of `(filename, criteria)` pairs. Only includes specs that
858/// have at least one criterion and are not marked as implemented.
859pub fn extract_all_criteria(
860    specs_dir: &Path,
861) -> std::io::Result<Vec<(String, Vec<AcceptanceCriterion>)>> {
862    let files = collect_spec_files(specs_dir)?;
863    let mut results = Vec::new();
864
865    for path in files {
866        let criteria = extract_criteria_from_file(&path);
867        if !criteria.is_empty() {
868            let filename = path
869                .file_name()
870                .unwrap_or_default()
871                .to_string_lossy()
872                .to_string();
873            results.push((filename, criteria));
874        }
875    }
876
877    Ok(results)
878}
879
880#[derive(Debug)]
881struct TelegramBotInfo {
882    username: String,
883}
884
885async fn telegram_get_me(token: &str) -> anyhow::Result<TelegramBotInfo> {
886    let url = format!("https://api.telegram.org/bot{}/getMe", token);
887    let client = reqwest::Client::new();
888    let resp = client
889        .get(&url)
890        .timeout(Duration::from_secs(10))
891        .send()
892        .await
893        .map_err(|err| anyhow::anyhow!("Network error calling Telegram API: {err}"))?;
894
895    let status = resp.status();
896    let body: serde_json::Value = resp
897        .json()
898        .await
899        .map_err(|err| anyhow::anyhow!("Failed to parse Telegram API response: {err}"))?;
900
901    if !status.is_success() || body.get("ok") != Some(&serde_json::Value::Bool(true)) {
902        let description = body
903            .get("description")
904            .and_then(|value| value.as_str())
905            .unwrap_or("Unknown error");
906        anyhow::bail!("Telegram API error: {description}");
907    }
908
909    let result = body
910        .get("result")
911        .ok_or_else(|| anyhow::anyhow!("Missing 'result' in Telegram response"))?;
912    let username = result
913        .get("username")
914        .and_then(|value| value.as_str())
915        .unwrap_or("unknown_bot")
916        .to_string();
917
918    Ok(TelegramBotInfo { username })
919}
920
921fn check_auto_backend(name: &str, config: &RalphConfig) -> CheckResult {
922    let priority = config.get_agent_priority();
923    if priority.is_empty() {
924        return CheckResult::fail(
925            name,
926            "Auto backend selection unavailable",
927            "No backend priority list configured",
928        );
929    }
930
931    let mut checked = Vec::new();
932
933    for backend in priority {
934        if !config.adapter_settings(backend).enabled {
935            continue;
936        }
937
938        let Some(command) = backend_command(backend, None) else {
939            continue;
940        };
941        checked.push(format!("{backend} ({command})"));
942        if command_supports_version(backend) {
943            if command_available(&command) {
944                return CheckResult::pass(name, format!("Auto backend available ({backend})"));
945            }
946        } else if find_executable(&command).is_some() {
947            return CheckResult::pass(name, format!("Auto backend available ({backend})"));
948        }
949    }
950
951    if checked.is_empty() {
952        return CheckResult::fail(
953            name,
954            "Auto backend selection unavailable",
955            "All configured adapters are disabled",
956        );
957    }
958
959    CheckResult::fail(
960        name,
961        "No available backend found",
962        format!("Checked: {}", checked.join(", ")),
963    )
964}
965
966fn check_named_backend(name: &str, config: &RalphConfig, backend: &str) -> CheckResult {
967    let command_override = config.cli.command.as_deref();
968    let Some(command) = backend_command(backend, command_override) else {
969        return CheckResult::fail(
970            name,
971            "Backend command missing",
972            "Set cli.command for custom backend",
973        );
974    };
975
976    if backend.eq_ignore_ascii_case("custom") {
977        if find_executable(&command).is_some() {
978            return CheckResult::pass(name, format!("Custom backend available ({})", command));
979        }
980
981        return CheckResult::fail(
982            name,
983            "Custom backend not found",
984            format!("Command not found: {}", command),
985        );
986    }
987
988    if command_available(&command) {
989        CheckResult::pass(name, format!("Backend CLI available ({})", command))
990    } else {
991        CheckResult::fail(
992            name,
993            "Backend CLI not available",
994            format!("Command not found or not executable: {}", command),
995        )
996    }
997}
998
999fn backend_command(backend: &str, override_cmd: Option<&str>) -> Option<String> {
1000    if let Some(command) = override_cmd {
1001        let trimmed = command.trim();
1002        if trimmed.is_empty() {
1003            return None;
1004        }
1005        return trimmed
1006            .split_whitespace()
1007            .next()
1008            .map(|value| value.to_string());
1009    }
1010
1011    match backend {
1012        "kiro" => Some("kiro-cli".to_string()),
1013        _ => Some(backend.to_string()),
1014    }
1015}
1016
1017fn command_supports_version(backend: &str) -> bool {
1018    !backend.eq_ignore_ascii_case("custom")
1019}
1020
1021fn command_available(command: &str) -> bool {
1022    Command::new(command)
1023        .arg("--version")
1024        .output()
1025        .map(|output| output.status.success())
1026        .unwrap_or(false)
1027}
1028
1029fn ensure_directory(path: &Path, created: &mut Vec<String>) -> anyhow::Result<()> {
1030    if path.exists() {
1031        if path.is_dir() {
1032            return Ok(());
1033        }
1034        anyhow::bail!("Path exists but is not a directory: {}", path.display());
1035    }
1036
1037    std::fs::create_dir_all(path)?;
1038    created.push(path.display().to_string());
1039    Ok(())
1040}
1041
1042fn find_executable(command: &str) -> Option<PathBuf> {
1043    let path = Path::new(command);
1044    if path.components().count() > 1 {
1045        return if path.is_file() {
1046            Some(path.to_path_buf())
1047        } else {
1048            None
1049        };
1050    }
1051
1052    let path_var = env::var_os("PATH")?;
1053    let extensions = executable_extensions();
1054
1055    for dir in env::split_paths(&path_var) {
1056        for ext in &extensions {
1057            let candidate = if ext.is_empty() {
1058                dir.join(command)
1059            } else {
1060                dir.join(format!("{}{}", command, ext.to_string_lossy()))
1061            };
1062
1063            if candidate.is_file() {
1064                return Some(candidate);
1065            }
1066        }
1067    }
1068
1069    None
1070}
1071
1072fn executable_extensions() -> Vec<OsString> {
1073    if cfg!(windows) {
1074        let exts = env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
1075        exts.split(';')
1076            .filter(|ext| !ext.trim().is_empty())
1077            .map(|ext| OsString::from(ext.trim().to_string()))
1078            .collect()
1079    } else {
1080        vec![OsString::new()]
1081    }
1082}
1083
1084fn is_git_workspace(path: &Path) -> bool {
1085    let git_dir = path.join(".git");
1086    git_dir.is_dir() || git_dir.is_file()
1087}
1088
1089fn format_config_warnings(warnings: &[ConfigWarning]) -> String {
1090    warnings
1091        .iter()
1092        .map(|warning| warning.to_string())
1093        .collect::<Vec<_>>()
1094        .join("\n")
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use super::*;
1100    use crate::{HookMutationConfig, HookOnError, HookPhaseEvent, HookSpec};
1101
1102    fn hook_spec(name: &str, command: &[&str]) -> HookSpec {
1103        HookSpec {
1104            name: name.to_string(),
1105            command: command.iter().map(|part| (*part).to_string()).collect(),
1106            cwd: None,
1107            env: HashMap::new(),
1108            timeout_seconds: None,
1109            max_output_bytes: None,
1110            on_error: Some(HookOnError::Block),
1111            suspend_mode: None,
1112            mutate: HookMutationConfig::default(),
1113            extra: HashMap::new(),
1114        }
1115    }
1116
1117    #[cfg(unix)]
1118    fn mark_executable(path: &std::path::Path) {
1119        use std::os::unix::fs::PermissionsExt;
1120
1121        let mut permissions = std::fs::metadata(path).expect("metadata").permissions();
1122        permissions.set_mode(0o755);
1123        std::fs::set_permissions(path, permissions).expect("set executable bit");
1124    }
1125
1126    #[cfg(not(unix))]
1127    fn mark_executable(_path: &std::path::Path) {}
1128
1129    #[tokio::test]
1130    async fn report_counts_statuses() {
1131        let checks = vec![
1132            CheckResult::pass("a", "ok"),
1133            CheckResult::warn("b", "warn", "needs attention"),
1134            CheckResult::fail("c", "fail", "broken"),
1135        ];
1136
1137        let report = PreflightReport::from_results(checks);
1138
1139        assert_eq!(report.warnings, 1);
1140        assert_eq!(report.failures, 1);
1141        assert!(!report.passed);
1142    }
1143
1144    #[test]
1145    fn default_checks_include_hooks_check_name() {
1146        let runner = PreflightRunner::default_checks();
1147        let check_names = runner.check_names();
1148
1149        assert!(check_names.contains(&"hooks"));
1150    }
1151
1152    #[tokio::test]
1153    async fn hooks_check_skips_when_hooks_are_disabled() {
1154        let config = RalphConfig::default();
1155        let check = HooksValidationCheck;
1156
1157        let result = check.run(&config).await;
1158
1159        assert_eq!(result.status, CheckStatus::Pass);
1160        assert_eq!(result.name, "hooks");
1161        assert!(result.label.contains("skipping"));
1162    }
1163
1164    #[tokio::test]
1165    async fn hooks_check_passes_with_resolvable_executable_command() {
1166        let temp = tempfile::tempdir().expect("tempdir");
1167        let script_dir = temp.path().join("scripts/hooks");
1168        std::fs::create_dir_all(&script_dir).expect("create script directory");
1169
1170        let script_path = script_dir.join("env-guard.sh");
1171        std::fs::write(&script_path, "#!/usr/bin/env sh\nexit 0\n").expect("write script");
1172        mark_executable(&script_path);
1173
1174        let mut config = RalphConfig::default();
1175        config.core.workspace_root = temp.path().to_path_buf();
1176        config.hooks.enabled = true;
1177        config.hooks.events.insert(
1178            HookPhaseEvent::PreLoopStart,
1179            vec![hook_spec("env-guard", &["./scripts/hooks/env-guard.sh"])],
1180        );
1181
1182        let check = HooksValidationCheck;
1183        let result = check.run(&config).await;
1184
1185        assert_eq!(result.status, CheckStatus::Pass);
1186        assert!(result.label.contains("Hooks validation passed"));
1187        assert!(result.label.contains("1 hook(s)"));
1188        assert!(result.message.is_none());
1189    }
1190
1191    #[tokio::test]
1192    async fn hooks_check_fails_with_actionable_duplicate_and_command_diagnostics() {
1193        let temp = tempfile::tempdir().expect("tempdir");
1194
1195        let mut config = RalphConfig::default();
1196        config.core.workspace_root = temp.path().to_path_buf();
1197        config.hooks.enabled = true;
1198        config.hooks.events.insert(
1199            HookPhaseEvent::PreLoopStart,
1200            vec![
1201                hook_spec("dup-hook", &["./scripts/hooks/missing-one.sh"]),
1202                hook_spec("dup-hook", &["./scripts/hooks/missing-two.sh"]),
1203            ],
1204        );
1205
1206        let check = HooksValidationCheck;
1207        let result = check.run(&config).await;
1208
1209        assert_eq!(result.status, CheckStatus::Fail);
1210        assert!(result.label.contains("Hooks validation failed"));
1211        let message = result.message.expect("expected failure diagnostics");
1212        assert!(message.contains("duplicate hook name 'dup-hook'"));
1213        assert!(message.contains("file does not exist"));
1214        assert!(message.contains("Fix: ensure command exists and is executable"));
1215    }
1216
1217    #[tokio::test]
1218    async fn run_selected_can_skip_hooks_check_failures() {
1219        let temp = tempfile::tempdir().expect("tempdir");
1220
1221        let mut config = RalphConfig::default();
1222        config.core.workspace_root = temp.path().to_path_buf();
1223        config.hooks.enabled = true;
1224        config.hooks.events.insert(
1225            HookPhaseEvent::PreLoopStart,
1226            vec![hook_spec("broken-hook", &["./scripts/hooks/missing.sh"])],
1227        );
1228
1229        let runner = PreflightRunner::default_checks();
1230        let report = runner.run_selected(&config, &["config".to_string()]).await;
1231
1232        assert!(report.passed);
1233        assert_eq!(report.failures, 0);
1234        assert_eq!(report.checks.len(), 1);
1235        assert_eq!(report.checks[0].name, "config");
1236    }
1237
1238    #[tokio::test]
1239    async fn config_check_emits_warning_details() {
1240        let mut config = RalphConfig::default();
1241        config.archive_prompts = true;
1242
1243        let check = ConfigValidCheck;
1244        let result = check.run(&config).await;
1245
1246        assert_eq!(result.status, CheckStatus::Warn);
1247        let message = result.message.expect("expected warning message");
1248        assert!(message.contains("archive_prompts"));
1249    }
1250
1251    #[tokio::test]
1252    async fn tools_check_reports_missing_tools() {
1253        let temp = tempfile::tempdir().expect("tempdir");
1254        std::fs::create_dir_all(temp.path().join(".git")).expect("create .git");
1255        let mut config = RalphConfig::default();
1256        config.core.workspace_root = temp.path().to_path_buf();
1257        let check = ToolsInPathCheck::new(vec!["definitely-not-a-tool".to_string()]);
1258
1259        let result = check.run(&config).await;
1260
1261        assert_eq!(result.status, CheckStatus::Fail);
1262        assert!(result.message.unwrap_or_default().contains("Missing"));
1263    }
1264
1265    #[tokio::test]
1266    async fn tools_check_warns_on_missing_optional_tools() {
1267        let temp = tempfile::tempdir().expect("tempdir");
1268        std::fs::create_dir_all(temp.path().join(".git")).expect("create .git");
1269        let mut config = RalphConfig::default();
1270        config.core.workspace_root = temp.path().to_path_buf();
1271        let check = ToolsInPathCheck::new_with_optional(
1272            Vec::new(),
1273            vec!["definitely-not-a-tool".to_string()],
1274        );
1275
1276        let result = check.run(&config).await;
1277
1278        assert_eq!(result.status, CheckStatus::Warn);
1279        assert!(result.message.unwrap_or_default().contains("Missing"));
1280    }
1281
1282    #[tokio::test]
1283    async fn paths_check_creates_missing_dirs() {
1284        let temp = tempfile::tempdir().expect("tempdir");
1285        let root = temp.path().to_path_buf();
1286
1287        let mut config = RalphConfig::default();
1288        config.core.workspace_root = root.clone();
1289        config.core.scratchpad = "nested/scratchpad.md".to_string();
1290        config.core.specs_dir = "nested/specs".to_string();
1291
1292        let check = PathsExistCheck;
1293        let result = check.run(&config).await;
1294
1295        assert!(root.join("nested").exists());
1296        assert!(root.join("nested/specs").exists());
1297        assert_eq!(result.status, CheckStatus::Warn);
1298    }
1299
1300    #[tokio::test]
1301    async fn telegram_check_skips_when_disabled() {
1302        let config = RalphConfig::default();
1303        let check = TelegramTokenCheck;
1304
1305        let result = check.run(&config).await;
1306
1307        assert_eq!(result.status, CheckStatus::Pass);
1308        assert!(result.label.contains("skipping"));
1309    }
1310
1311    #[tokio::test]
1312    async fn git_check_skips_outside_repo() {
1313        let temp = tempfile::tempdir().expect("tempdir");
1314        let mut config = RalphConfig::default();
1315        config.core.workspace_root = temp.path().to_path_buf();
1316
1317        let check = GitCleanCheck;
1318        let result = check.run(&config).await;
1319
1320        assert_eq!(result.status, CheckStatus::Pass);
1321        assert!(result.label.contains("skipping"));
1322    }
1323
1324    #[tokio::test]
1325    async fn tools_check_skips_outside_repo() {
1326        let temp = tempfile::tempdir().expect("tempdir");
1327        let mut config = RalphConfig::default();
1328        config.core.workspace_root = temp.path().to_path_buf();
1329
1330        let check = ToolsInPathCheck::new(vec!["definitely-not-a-tool".to_string()]);
1331        let result = check.run(&config).await;
1332
1333        assert_eq!(result.status, CheckStatus::Pass);
1334        assert!(result.label.contains("skipping"));
1335    }
1336
1337    #[tokio::test]
1338    async fn specs_check_skips_when_no_directory() {
1339        let temp = tempfile::tempdir().expect("tempdir");
1340        let mut config = RalphConfig::default();
1341        config.core.workspace_root = temp.path().to_path_buf();
1342        config.core.specs_dir = "nonexistent/specs".to_string();
1343
1344        let check = SpecCompletenessCheck;
1345        let result = check.run(&config).await;
1346
1347        assert_eq!(result.status, CheckStatus::Pass);
1348        assert!(result.label.contains("skipping"));
1349    }
1350
1351    #[tokio::test]
1352    async fn specs_check_skips_when_empty_directory() {
1353        let temp = tempfile::tempdir().expect("tempdir");
1354        std::fs::create_dir_all(temp.path().join("specs")).expect("create specs dir");
1355        let mut config = RalphConfig::default();
1356        config.core.workspace_root = temp.path().to_path_buf();
1357        config.core.specs_dir = "specs".to_string();
1358
1359        let check = SpecCompletenessCheck;
1360        let result = check.run(&config).await;
1361
1362        assert_eq!(result.status, CheckStatus::Pass);
1363        assert!(result.label.contains("skipping"));
1364    }
1365
1366    #[tokio::test]
1367    async fn specs_check_passes_with_complete_spec() {
1368        let temp = tempfile::tempdir().expect("tempdir");
1369        let specs_dir = temp.path().join("specs");
1370        std::fs::create_dir_all(&specs_dir).expect("create specs dir");
1371        std::fs::write(
1372            specs_dir.join("feature.spec.md"),
1373            r"---
1374status: draft
1375---
1376
1377# Feature Spec
1378
1379## Goal
1380
1381Add a new feature.
1382
1383## Acceptance Criteria
1384
1385**Given** the system is running
1386**When** the user triggers the feature
1387**Then** the expected output is produced
1388",
1389        )
1390        .expect("write spec");
1391
1392        let mut config = RalphConfig::default();
1393        config.core.workspace_root = temp.path().to_path_buf();
1394        config.core.specs_dir = "specs".to_string();
1395
1396        let check = SpecCompletenessCheck;
1397        let result = check.run(&config).await;
1398
1399        assert_eq!(result.status, CheckStatus::Pass);
1400        assert!(result.label.contains("1 spec(s) valid"));
1401    }
1402
1403    #[tokio::test]
1404    async fn specs_check_warns_on_missing_acceptance_criteria() {
1405        let temp = tempfile::tempdir().expect("tempdir");
1406        let specs_dir = temp.path().join("specs");
1407        std::fs::create_dir_all(&specs_dir).expect("create specs dir");
1408        std::fs::write(
1409            specs_dir.join("incomplete.spec.md"),
1410            r"---
1411status: draft
1412---
1413
1414# Incomplete Spec
1415
1416## Goal
1417
1418Do something.
1419
1420## Requirements
1421
14221. Some requirement
1423",
1424        )
1425        .expect("write spec");
1426
1427        let mut config = RalphConfig::default();
1428        config.core.workspace_root = temp.path().to_path_buf();
1429        config.core.specs_dir = "specs".to_string();
1430
1431        let check = SpecCompletenessCheck;
1432        let result = check.run(&config).await;
1433
1434        assert_eq!(result.status, CheckStatus::Warn);
1435        assert!(result.label.contains("missing acceptance criteria"));
1436        let message = result.message.expect("expected message");
1437        assert!(message.contains("incomplete.spec.md"));
1438    }
1439
1440    #[tokio::test]
1441    async fn specs_check_skips_implemented_specs() {
1442        let temp = tempfile::tempdir().expect("tempdir");
1443        let specs_dir = temp.path().join("specs");
1444        std::fs::create_dir_all(&specs_dir).expect("create specs dir");
1445        // This spec lacks acceptance criteria but is already implemented
1446        std::fs::write(
1447            specs_dir.join("done.spec.md"),
1448            r"---
1449status: implemented
1450---
1451
1452# Done Spec
1453
1454## Goal
1455
1456Already done.
1457",
1458        )
1459        .expect("write spec");
1460
1461        let mut config = RalphConfig::default();
1462        config.core.workspace_root = temp.path().to_path_buf();
1463        config.core.specs_dir = "specs".to_string();
1464
1465        let check = SpecCompletenessCheck;
1466        let result = check.run(&config).await;
1467
1468        assert_eq!(result.status, CheckStatus::Pass);
1469    }
1470
1471    #[tokio::test]
1472    async fn specs_check_finds_specs_in_subdirectories() {
1473        let temp = tempfile::tempdir().expect("tempdir");
1474        let specs_dir = temp.path().join("specs");
1475        let sub_dir = specs_dir.join("adapters");
1476        std::fs::create_dir_all(&sub_dir).expect("create subdirectory");
1477        std::fs::write(
1478            sub_dir.join("adapter.spec.md"),
1479            r"---
1480status: draft
1481---
1482
1483# Adapter Spec
1484
1485## Acceptance Criteria
1486
1487- **Given** an adapter is configured
1488- **When** a request is sent
1489- **Then** the adapter responds correctly
1490",
1491        )
1492        .expect("write spec");
1493
1494        let mut config = RalphConfig::default();
1495        config.core.workspace_root = temp.path().to_path_buf();
1496        config.core.specs_dir = "specs".to_string();
1497
1498        let check = SpecCompletenessCheck;
1499        let result = check.run(&config).await;
1500
1501        assert_eq!(result.status, CheckStatus::Pass);
1502        assert!(result.label.contains("1 spec(s) valid"));
1503    }
1504
1505    #[test]
1506    fn has_acceptance_criteria_detects_bold_format() {
1507        let content = r"
1508## Acceptance Criteria
1509
1510**Given** the system is ready
1511**When** the user clicks
1512**Then** the result appears
1513";
1514        assert!(has_acceptance_criteria(content));
1515    }
1516
1517    #[test]
1518    fn has_acceptance_criteria_detects_list_format() {
1519        let content = r"
1520## Acceptance Criteria
1521
1522- Given the system is ready
1523- When the user clicks
1524- Then the result appears
1525";
1526        assert!(has_acceptance_criteria(content));
1527    }
1528
1529    #[test]
1530    fn has_acceptance_criteria_detects_bold_list_format() {
1531        let content = r"
1532## Acceptance Criteria
1533
1534- **Given** the system is ready
1535- **When** the user clicks
1536- **Then** the result appears
1537";
1538        assert!(has_acceptance_criteria(content));
1539    }
1540
1541    #[test]
1542    fn has_acceptance_criteria_requires_given_and_then() {
1543        // Only has Given, missing When and Then
1544        let content = "**Given** something\n";
1545        assert!(!has_acceptance_criteria(content));
1546
1547        // Has Given and Then (When implicit) — should pass
1548        let content = "**Given** something\n**Then** result\n";
1549        assert!(has_acceptance_criteria(content));
1550    }
1551
1552    #[test]
1553    fn has_acceptance_criteria_rejects_content_without_criteria() {
1554        let content = r"
1555# Some Spec
1556
1557## Goal
1558
1559Build something.
1560
1561## Requirements
1562
15631. It should work.
1564";
1565        assert!(!has_acceptance_criteria(content));
1566    }
1567
1568    // --- extract_acceptance_criteria tests ---
1569
1570    #[test]
1571    fn extract_criteria_bold_format() {
1572        let content = r#"
1573## Acceptance Criteria
1574
1575**Given** `backend: "amp"` in config
1576**When** Ralph executes an iteration
1577**Then** both flags are included
1578"#;
1579        let criteria = extract_acceptance_criteria(content);
1580        assert_eq!(criteria.len(), 1);
1581        assert_eq!(criteria[0].given, "`backend: \"amp\"` in config");
1582        assert_eq!(
1583            criteria[0].when.as_deref(),
1584            Some("Ralph executes an iteration")
1585        );
1586        assert_eq!(criteria[0].then, "both flags are included");
1587    }
1588
1589    #[test]
1590    fn extract_criteria_multiple_triples() {
1591        let content = r"
1592**Given** system A is running
1593**When** user clicks button
1594**Then** dialog appears
1595
1596**Given** dialog is open
1597**When** user confirms
1598**Then** action completes
1599";
1600        let criteria = extract_acceptance_criteria(content);
1601        assert_eq!(criteria.len(), 2);
1602        assert_eq!(criteria[0].given, "system A is running");
1603        assert_eq!(criteria[1].given, "dialog is open");
1604        assert_eq!(criteria[1].then, "action completes");
1605    }
1606
1607    #[test]
1608    fn extract_criteria_list_format() {
1609        let content = r"
1610## Acceptance Criteria
1611
1612- **Given** an adapter is configured
1613- **When** a request is sent
1614- **Then** the adapter responds correctly
1615";
1616        let criteria = extract_acceptance_criteria(content);
1617        assert_eq!(criteria.len(), 1);
1618        assert_eq!(criteria[0].given, "an adapter is configured");
1619        assert_eq!(criteria[0].when.as_deref(), Some("a request is sent"));
1620        assert_eq!(criteria[0].then, "the adapter responds correctly");
1621    }
1622
1623    #[test]
1624    fn extract_criteria_plain_text_format() {
1625        let content = r"
1626Given the server is started
1627When a GET request is sent
1628Then a 200 response is returned
1629";
1630        let criteria = extract_acceptance_criteria(content);
1631        assert_eq!(criteria.len(), 1);
1632        assert_eq!(criteria[0].given, "the server is started");
1633        assert_eq!(criteria[0].when.as_deref(), Some("a GET request is sent"));
1634        assert_eq!(criteria[0].then, "a 200 response is returned");
1635    }
1636
1637    #[test]
1638    fn extract_criteria_given_then_without_when() {
1639        let content = r"
1640**Given** the config is empty
1641**Then** defaults are used
1642";
1643        let criteria = extract_acceptance_criteria(content);
1644        assert_eq!(criteria.len(), 1);
1645        assert_eq!(criteria[0].given, "the config is empty");
1646        assert!(criteria[0].when.is_none());
1647        assert_eq!(criteria[0].then, "defaults are used");
1648    }
1649
1650    #[test]
1651    fn extract_criteria_empty_content() {
1652        let criteria = extract_acceptance_criteria("");
1653        assert!(criteria.is_empty());
1654    }
1655
1656    #[test]
1657    fn extract_criteria_no_criteria() {
1658        let content = r"
1659# Spec
1660
1661## Goal
1662
1663Build something.
1664";
1665        let criteria = extract_acceptance_criteria(content);
1666        assert!(criteria.is_empty());
1667    }
1668
1669    #[test]
1670    fn extract_criteria_incomplete_given_without_then_is_dropped() {
1671        let content = r"
1672**Given** orphan precondition
1673
1674Some other text here.
1675";
1676        let criteria = extract_acceptance_criteria(content);
1677        assert!(criteria.is_empty());
1678    }
1679
1680    #[test]
1681    fn extract_criteria_from_file_skips_implemented() {
1682        let temp = tempfile::tempdir().expect("tempdir");
1683        let path = temp.path().join("done.spec.md");
1684        std::fs::write(
1685            &path,
1686            r"---
1687status: implemented
1688---
1689
1690**Given** something
1691**When** something happens
1692**Then** result
1693",
1694        )
1695        .expect("write");
1696
1697        let criteria = extract_criteria_from_file(&path);
1698        assert!(criteria.is_empty());
1699    }
1700
1701    #[test]
1702    fn extract_criteria_from_file_returns_criteria() {
1703        let temp = tempfile::tempdir().expect("tempdir");
1704        let path = temp.path().join("feature.spec.md");
1705        std::fs::write(
1706            &path,
1707            r"---
1708status: draft
1709---
1710
1711# Feature
1712
1713**Given** the system is ready
1714**When** user acts
1715**Then** feature works
1716",
1717        )
1718        .expect("write");
1719
1720        let criteria = extract_criteria_from_file(&path);
1721        assert_eq!(criteria.len(), 1);
1722        assert_eq!(criteria[0].given, "the system is ready");
1723    }
1724
1725    #[test]
1726    fn extract_all_criteria_collects_from_directory() {
1727        let temp = tempfile::tempdir().expect("tempdir");
1728        let specs_dir = temp.path().join("specs");
1729        std::fs::create_dir_all(&specs_dir).expect("create dir");
1730
1731        std::fs::write(
1732            specs_dir.join("a.spec.md"),
1733            "**Given** A\n**When** B\n**Then** C\n",
1734        )
1735        .expect("write a");
1736
1737        std::fs::write(specs_dir.join("b.spec.md"), "**Given** X\n**Then** Y\n").expect("write b");
1738
1739        // Implemented spec should be excluded
1740        std::fs::write(
1741            specs_dir.join("c.spec.md"),
1742            "---\nstatus: implemented\n---\n**Given** skip\n**Then** skip\n",
1743        )
1744        .expect("write c");
1745
1746        let results = extract_all_criteria(&specs_dir).expect("extract");
1747        assert_eq!(results.len(), 2);
1748
1749        let filenames: Vec<&str> = results.iter().map(|(f, _)| f.as_str()).collect();
1750        assert!(filenames.contains(&"a.spec.md"));
1751        assert!(filenames.contains(&"b.spec.md"));
1752    }
1753
1754    #[test]
1755    fn match_clause_extracts_text() {
1756        assert_eq!(
1757            match_clause("**given** the system", "**Given** the system", "given"),
1758            Some("the system".to_string())
1759        );
1760        assert_eq!(
1761            match_clause("- **when** user clicks", "- **When** user clicks", "when"),
1762            Some("user clicks".to_string())
1763        );
1764        assert_eq!(
1765            match_clause("then result", "Then result", "then"),
1766            Some("result".to_string())
1767        );
1768        assert_eq!(
1769            match_clause("- given something", "- Given something", "given"),
1770            Some("something".to_string())
1771        );
1772        assert_eq!(
1773            match_clause("no match here", "No match here", "given"),
1774            None
1775        );
1776    }
1777}