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        if config.robot.mode.is_web() {
406            return CheckResult::pass(self.name(), "RObot web mode (skipping Telegram)");
407        }
408
409        let Some(token) = config.robot.resolve_bot_token() else {
410            return CheckResult::fail(
411                self.name(),
412                "Telegram token missing",
413                "Set RALPH_TELEGRAM_BOT_TOKEN or configure RObot.telegram.bot_token",
414            );
415        };
416
417        match telegram_get_me(&token).await {
418            Ok(info) => {
419                CheckResult::pass(self.name(), format!("Bot token valid (@{})", info.username))
420            }
421            Err(err) => CheckResult::fail(self.name(), "Telegram token invalid", format!("{err}")),
422        }
423    }
424}
425
426struct GitCleanCheck;
427
428#[async_trait]
429impl PreflightCheck for GitCleanCheck {
430    fn name(&self) -> &'static str {
431        "git"
432    }
433
434    async fn run(&self, config: &RalphConfig) -> CheckResult {
435        let root = &config.core.workspace_root;
436        if !is_git_workspace(root) {
437            return CheckResult::pass(self.name(), "Not a git repository (skipping)");
438        }
439
440        let branch = match git_ops::get_current_branch(root) {
441            Ok(branch) => branch,
442            Err(err) => {
443                return CheckResult::fail(
444                    self.name(),
445                    "Git repository unavailable",
446                    format!("{err}"),
447                );
448            }
449        };
450
451        match git_ops::is_working_tree_clean(root) {
452            Ok(true) => CheckResult::pass(self.name(), format!("Working tree clean ({branch})")),
453            Ok(false) => CheckResult::warn(
454                self.name(),
455                "Working tree has uncommitted changes",
456                "Commit or stash changes before running for clean diffs",
457            ),
458            Err(err) => {
459                CheckResult::fail(self.name(), "Unable to read git status", format!("{err}"))
460            }
461        }
462    }
463}
464
465struct PathsExistCheck;
466
467#[async_trait]
468impl PreflightCheck for PathsExistCheck {
469    fn name(&self) -> &'static str {
470        "paths"
471    }
472
473    async fn run(&self, config: &RalphConfig) -> CheckResult {
474        let mut created = Vec::new();
475
476        let scratchpad_path = config.core.resolve_path(&config.core.scratchpad.path);
477        if let Some(parent) = scratchpad_path.parent()
478            && let Err(err) = ensure_directory(parent, &mut created)
479        {
480            return CheckResult::fail(
481                self.name(),
482                "Scratchpad path unavailable",
483                format!("{}", err),
484            );
485        }
486
487        let specs_path = config.core.resolve_path(&config.core.specs_dir);
488        if let Err(err) = ensure_directory(&specs_path, &mut created) {
489            return CheckResult::fail(
490                self.name(),
491                "Specs directory unavailable",
492                format!("{}", err),
493            );
494        }
495
496        if created.is_empty() {
497            CheckResult::pass(self.name(), "Workspace paths accessible")
498        } else {
499            CheckResult::warn(
500                self.name(),
501                "Workspace paths created",
502                format!("Created: {}", created.join(", ")),
503            )
504        }
505    }
506}
507
508#[derive(Debug, Clone)]
509struct ToolsInPathCheck {
510    required: Vec<String>,
511    optional: Vec<String>,
512}
513
514impl ToolsInPathCheck {
515    #[cfg(test)]
516    fn new(required: Vec<String>) -> Self {
517        Self {
518            required,
519            optional: Vec::new(),
520        }
521    }
522
523    fn new_with_optional(required: Vec<String>, optional: Vec<String>) -> Self {
524        Self { required, optional }
525    }
526}
527
528impl Default for ToolsInPathCheck {
529    fn default() -> Self {
530        Self::new_with_optional(vec!["git".to_string()], Vec::new())
531    }
532}
533
534#[async_trait]
535impl PreflightCheck for ToolsInPathCheck {
536    fn name(&self) -> &'static str {
537        "tools"
538    }
539
540    async fn run(&self, config: &RalphConfig) -> CheckResult {
541        if !is_git_workspace(&config.core.workspace_root) {
542            return CheckResult::pass(self.name(), "Not a git repository (skipping)");
543        }
544
545        let missing_required: Vec<String> = self
546            .required
547            .iter()
548            .filter(|tool| find_executable(tool).is_none())
549            .cloned()
550            .collect();
551
552        let missing_optional: Vec<String> = self
553            .optional
554            .iter()
555            .filter(|tool| find_executable(tool).is_none())
556            .cloned()
557            .collect();
558
559        if missing_required.is_empty() && missing_optional.is_empty() {
560            let mut tools = self.required.clone();
561            tools.extend(self.optional.clone());
562            CheckResult::pass(
563                self.name(),
564                format!("Required tools available ({})", tools.join(", ")),
565            )
566        } else if missing_required.is_empty() {
567            CheckResult::warn(
568                self.name(),
569                "Missing optional tools",
570                format!("Missing: {}", missing_optional.join(", ")),
571            )
572        } else {
573            let mut detail = format!("required: {}", missing_required.join(", "));
574            if !missing_optional.is_empty() {
575                detail.push_str(&format!("; optional: {}", missing_optional.join(", ")));
576            }
577            CheckResult::fail(
578                self.name(),
579                "Missing required tools",
580                format!("Missing {}", detail),
581            )
582        }
583    }
584}
585
586struct SpecCompletenessCheck;
587
588#[async_trait]
589impl PreflightCheck for SpecCompletenessCheck {
590    fn name(&self) -> &'static str {
591        "specs"
592    }
593
594    async fn run(&self, config: &RalphConfig) -> CheckResult {
595        let specs_dir = config.core.resolve_path(&config.core.specs_dir);
596
597        if !specs_dir.exists() {
598            return CheckResult::pass(self.name(), "No specs directory (skipping)");
599        }
600
601        let spec_files = match collect_spec_files(&specs_dir) {
602            Ok(files) => files,
603            Err(err) => {
604                return CheckResult::fail(
605                    self.name(),
606                    "Unable to read specs directory",
607                    format!("{err}"),
608                );
609            }
610        };
611
612        if spec_files.is_empty() {
613            return CheckResult::pass(self.name(), "No spec files found (skipping)");
614        }
615
616        let mut incomplete: Vec<String> = Vec::new();
617
618        for path in &spec_files {
619            let content = match std::fs::read_to_string(path) {
620                Ok(c) => c,
621                Err(err) => {
622                    incomplete.push(format!(
623                        "{}: unreadable ({})",
624                        path.file_name().unwrap_or_default().to_string_lossy(),
625                        err
626                    ));
627                    continue;
628                }
629            };
630
631            if let Some(reason) = check_spec_completeness(path, &content) {
632                incomplete.push(reason);
633            }
634        }
635
636        if incomplete.is_empty() {
637            CheckResult::pass(
638                self.name(),
639                format!(
640                    "{} spec(s) valid with acceptance criteria",
641                    spec_files.len()
642                ),
643            )
644        } else {
645            let total = spec_files.len();
646            CheckResult::warn(
647                self.name(),
648                format!(
649                    "{} of {} spec(s) missing acceptance criteria",
650                    incomplete.len(),
651                    total
652                ),
653                format!(
654                    "Specs should include Given/When/Then acceptance criteria.\n{}",
655                    incomplete.join("\n")
656                ),
657            )
658        }
659    }
660}
661
662/// Recursively collect all `.spec.md` files under a directory.
663fn collect_spec_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
664    let mut files = Vec::new();
665    collect_spec_files_recursive(dir, &mut files)?;
666    files.sort();
667    Ok(files)
668}
669
670fn collect_spec_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
671    for entry in std::fs::read_dir(dir)? {
672        let entry = entry?;
673        let path = entry.path();
674        if path.is_dir() {
675            collect_spec_files_recursive(&path, files)?;
676        } else if path
677            .file_name()
678            .and_then(|n| n.to_str())
679            .is_some_and(|n| n.ends_with(".spec.md"))
680        {
681            files.push(path);
682        }
683    }
684    Ok(())
685}
686
687/// Check whether a spec file has the required sections for Level 5 completeness.
688///
689/// Returns `None` if the spec is complete, or `Some(reason)` if incomplete.
690fn check_spec_completeness(path: &Path, content: &str) -> Option<String> {
691    let filename = path
692        .file_name()
693        .unwrap_or_default()
694        .to_string_lossy()
695        .to_string();
696
697    // Skip specs that are already marked as implemented — they passed review
698    let content_lower = content.to_lowercase();
699    if content_lower.contains("status: implemented") {
700        return None;
701    }
702
703    let has_acceptance = has_acceptance_criteria(content);
704
705    if !has_acceptance {
706        return Some(format!(
707            "{filename}: missing acceptance criteria (Given/When/Then)"
708        ));
709    }
710
711    None
712}
713
714/// Detect whether content contains Given/When/Then acceptance criteria.
715///
716/// Matches common spec patterns:
717/// - `**Given**` / `**When**` / `**Then**` (bold markdown)
718/// - `Given ` / `When ` / `Then ` at line start (plain text)
719/// - `- Given ` / `- When ` / `- Then ` (list items)
720fn has_acceptance_criteria(content: &str) -> bool {
721    let mut has_given = false;
722    let mut has_when = false;
723    let mut has_then = false;
724
725    for line in content.lines() {
726        let trimmed = line.trim();
727        let lower = trimmed.to_lowercase();
728
729        // Bold markdown: **Given**, **When**, **Then**
730        // Plain text at line start: Given, When, Then
731        // List items: - Given, - When, - Then
732        if lower.starts_with("**given**")
733            || lower.starts_with("given ")
734            || lower.starts_with("- given ")
735            || lower.starts_with("- **given**")
736        {
737            has_given = true;
738        }
739        if lower.starts_with("**when**")
740            || lower.starts_with("when ")
741            || lower.starts_with("- when ")
742            || lower.starts_with("- **when**")
743        {
744            has_when = true;
745        }
746        if lower.starts_with("**then**")
747            || lower.starts_with("then ")
748            || lower.starts_with("- then ")
749            || lower.starts_with("- **then**")
750        {
751            has_then = true;
752        }
753
754        if has_given && has_when && has_then {
755            return true;
756        }
757    }
758
759    // Require at least Given+Then (When is sometimes implicit)
760    has_given && has_then
761}
762
763/// A single acceptance criterion extracted from a spec file.
764#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
765pub struct AcceptanceCriterion {
766    /// The precondition (Given clause).
767    pub given: String,
768    /// The action or trigger (When clause). Optional because some specs omit it.
769    pub when: Option<String>,
770    /// The expected outcome (Then clause).
771    pub then: String,
772}
773
774/// Extract structured Given/When/Then acceptance criteria from spec content.
775///
776/// Parses the same patterns recognized by [`has_acceptance_criteria`] but returns
777/// structured triples instead of a boolean. Each contiguous Given[/When]/Then
778/// group produces one [`AcceptanceCriterion`].
779pub fn extract_acceptance_criteria(content: &str) -> Vec<AcceptanceCriterion> {
780    let mut criteria = Vec::new();
781    let mut current_given: Option<String> = None;
782    let mut current_when: Option<String> = None;
783
784    for line in content.lines() {
785        let trimmed = line.trim();
786        let lower = trimmed.to_lowercase();
787
788        if let Some(text) = match_clause(&lower, trimmed, "given") {
789            // Flush any previous incomplete criterion before starting a new Given
790            if let Some(given) = current_given.take() {
791                // Previous Given without Then — skip incomplete criterion
792                let _ = given;
793            }
794            current_given = Some(text);
795            current_when = None;
796        } else if let Some(text) = match_clause(&lower, trimmed, "when") {
797            current_when = Some(text);
798        } else if let Some(text) = match_clause(&lower, trimmed, "then") {
799            if let Some(given) = current_given.take() {
800                criteria.push(AcceptanceCriterion {
801                    given,
802                    when: current_when.take(),
803                    then: text,
804                });
805            }
806            // Reset for next criterion
807            current_when = None;
808        }
809    }
810
811    criteria
812}
813
814/// Match a Given/When/Then clause line and extract the text after the keyword.
815///
816/// Handles bold (`**Given**`), plain (`Given `), list (`- Given `), and bold-list
817/// (`- **Given**`) formats. Returns the text portion after the keyword, or `None`
818/// if the line doesn't match.
819fn match_clause(lower: &str, original: &str, keyword: &str) -> Option<String> {
820    let bold = format!("**{keyword}**");
821    let plain = format!("{keyword} ");
822    let list_plain = format!("- {keyword} ");
823    let list_bold = format!("- **{keyword}**");
824
825    // Determine the offset where the actual text starts
826    let text_start = if lower.starts_with(&bold) {
827        Some(bold.len())
828    } else if lower.starts_with(&list_bold) {
829        Some(list_bold.len())
830    } else if lower.starts_with(&list_plain) {
831        Some(list_plain.len())
832    } else if lower.starts_with(&plain) {
833        Some(plain.len())
834    } else {
835        None
836    };
837
838    text_start.map(|offset| original[offset..].trim().to_string())
839}
840
841/// Extract acceptance criteria from a spec file at the given path.
842///
843/// Reads the file, skips `status: implemented` specs, and returns structured
844/// criteria. Returns an empty vec if the file is unreadable or already implemented.
845pub fn extract_criteria_from_file(path: &Path) -> Vec<AcceptanceCriterion> {
846    let content = match std::fs::read_to_string(path) {
847        Ok(c) => c,
848        Err(_) => return Vec::new(),
849    };
850
851    // Skip implemented specs
852    if content.to_lowercase().contains("status: implemented") {
853        return Vec::new();
854    }
855
856    extract_acceptance_criteria(&content)
857}
858
859/// Extract acceptance criteria from all spec files in a directory.
860///
861/// Returns a vec of `(filename, criteria)` pairs. Only includes specs that
862/// have at least one criterion and are not marked as implemented.
863pub fn extract_all_criteria(
864    specs_dir: &Path,
865) -> std::io::Result<Vec<(String, Vec<AcceptanceCriterion>)>> {
866    let files = collect_spec_files(specs_dir)?;
867    let mut results = Vec::new();
868
869    for path in files {
870        let criteria = extract_criteria_from_file(&path);
871        if !criteria.is_empty() {
872            let filename = path
873                .file_name()
874                .unwrap_or_default()
875                .to_string_lossy()
876                .to_string();
877            results.push((filename, criteria));
878        }
879    }
880
881    Ok(results)
882}
883
884#[derive(Debug)]
885struct TelegramBotInfo {
886    username: String,
887}
888
889async fn telegram_get_me(token: &str) -> anyhow::Result<TelegramBotInfo> {
890    let url = format!("https://api.telegram.org/bot{}/getMe", token);
891    let client = reqwest::Client::new();
892    let resp = client
893        .get(&url)
894        .timeout(Duration::from_secs(10))
895        .send()
896        .await
897        .map_err(|err| anyhow::anyhow!("Network error calling Telegram API: {err}"))?;
898
899    let status = resp.status();
900    let body: serde_json::Value = resp
901        .json()
902        .await
903        .map_err(|err| anyhow::anyhow!("Failed to parse Telegram API response: {err}"))?;
904
905    if !status.is_success() || body.get("ok") != Some(&serde_json::Value::Bool(true)) {
906        let description = body
907            .get("description")
908            .and_then(|value| value.as_str())
909            .unwrap_or("Unknown error");
910        anyhow::bail!("Telegram API error: {description}");
911    }
912
913    let result = body
914        .get("result")
915        .ok_or_else(|| anyhow::anyhow!("Missing 'result' in Telegram response"))?;
916    let username = result
917        .get("username")
918        .and_then(|value| value.as_str())
919        .unwrap_or("unknown_bot")
920        .to_string();
921
922    Ok(TelegramBotInfo { username })
923}
924
925fn check_auto_backend(name: &str, config: &RalphConfig) -> CheckResult {
926    let priority = config.get_agent_priority();
927    if priority.is_empty() {
928        return CheckResult::fail(
929            name,
930            "Auto backend selection unavailable",
931            "No backend priority list configured",
932        );
933    }
934
935    let mut checked = Vec::new();
936
937    for backend in priority {
938        if !config.adapter_settings(backend).enabled {
939            continue;
940        }
941
942        let Some(command) = backend_command(backend, None) else {
943            continue;
944        };
945        checked.push(format!("{backend} ({command})"));
946        if command_supports_version(backend) {
947            if command_available(&command) {
948                return CheckResult::pass(name, format!("Auto backend available ({backend})"));
949            }
950        } else if find_executable(&command).is_some() {
951            return CheckResult::pass(name, format!("Auto backend available ({backend})"));
952        }
953    }
954
955    if checked.is_empty() {
956        return CheckResult::fail(
957            name,
958            "Auto backend selection unavailable",
959            "All configured adapters are disabled",
960        );
961    }
962
963    CheckResult::fail(
964        name,
965        "No available backend found",
966        format!("Checked: {}", checked.join(", ")),
967    )
968}
969
970fn check_named_backend(name: &str, config: &RalphConfig, backend: &str) -> CheckResult {
971    let command_override = config.cli.command.as_deref();
972    let Some(command) = backend_command(backend, command_override) else {
973        return CheckResult::fail(
974            name,
975            "Backend command missing",
976            "Set cli.command for custom backend",
977        );
978    };
979
980    if backend.eq_ignore_ascii_case("custom") {
981        if find_executable(&command).is_some() {
982            return CheckResult::pass(name, format!("Custom backend available ({})", command));
983        }
984
985        return CheckResult::fail(
986            name,
987            "Custom backend not found",
988            format!("Command not found: {}", command),
989        );
990    }
991
992    if command_available(&command) {
993        CheckResult::pass(name, format!("Backend CLI available ({})", command))
994    } else {
995        CheckResult::fail(
996            name,
997            "Backend CLI not available",
998            format!("Command not found or not executable: {}", command),
999        )
1000    }
1001}
1002
1003fn backend_command(backend: &str, override_cmd: Option<&str>) -> Option<String> {
1004    if let Some(command) = override_cmd {
1005        let trimmed = command.trim();
1006        if trimmed.is_empty() {
1007            return None;
1008        }
1009        return trimmed
1010            .split_whitespace()
1011            .next()
1012            .map(|value| value.to_string());
1013    }
1014
1015    match backend {
1016        "kiro" | "kiro-acp" => Some("kiro-cli".to_string()),
1017        _ => Some(backend.to_string()),
1018    }
1019}
1020
1021fn command_supports_version(backend: &str) -> bool {
1022    !backend.eq_ignore_ascii_case("custom")
1023}
1024
1025fn command_available(command: &str) -> bool {
1026    Command::new(command)
1027        .arg("--version")
1028        .output()
1029        .map(|output| output.status.success())
1030        .unwrap_or(false)
1031}
1032
1033fn ensure_directory(path: &Path, created: &mut Vec<String>) -> anyhow::Result<()> {
1034    if path.exists() {
1035        if path.is_dir() {
1036            return Ok(());
1037        }
1038        anyhow::bail!("Path exists but is not a directory: {}", path.display());
1039    }
1040
1041    std::fs::create_dir_all(path)?;
1042    created.push(path.display().to_string());
1043    Ok(())
1044}
1045
1046fn find_executable(command: &str) -> Option<PathBuf> {
1047    let path = Path::new(command);
1048    if path.components().count() > 1 {
1049        return if path.is_file() {
1050            Some(path.to_path_buf())
1051        } else {
1052            None
1053        };
1054    }
1055
1056    let path_var = env::var_os("PATH")?;
1057    let extensions = executable_extensions();
1058
1059    for dir in env::split_paths(&path_var) {
1060        for ext in &extensions {
1061            let candidate = if ext.is_empty() {
1062                dir.join(command)
1063            } else {
1064                dir.join(format!("{}{}", command, ext.to_string_lossy()))
1065            };
1066
1067            if candidate.is_file() {
1068                return Some(candidate);
1069            }
1070        }
1071    }
1072
1073    None
1074}
1075
1076fn executable_extensions() -> Vec<OsString> {
1077    if cfg!(windows) {
1078        let exts = env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
1079        exts.split(';')
1080            .filter(|ext| !ext.trim().is_empty())
1081            .map(|ext| OsString::from(ext.trim().to_string()))
1082            .collect()
1083    } else {
1084        vec![OsString::new()]
1085    }
1086}
1087
1088fn is_git_workspace(path: &Path) -> bool {
1089    let git_dir = path.join(".git");
1090    git_dir.is_dir() || git_dir.is_file()
1091}
1092
1093fn format_config_warnings(warnings: &[ConfigWarning]) -> String {
1094    warnings
1095        .iter()
1096        .map(|warning| warning.to_string())
1097        .collect::<Vec<_>>()
1098        .join("\n")
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    use super::*;
1104    use crate::{HookMutationConfig, HookOnError, HookPhaseEvent, HookSpec};
1105
1106    fn hook_spec(name: &str, command: &[&str]) -> HookSpec {
1107        HookSpec {
1108            name: name.to_string(),
1109            command: command.iter().map(|part| (*part).to_string()).collect(),
1110            cwd: None,
1111            env: HashMap::new(),
1112            timeout_seconds: None,
1113            max_output_bytes: None,
1114            on_error: Some(HookOnError::Block),
1115            suspend_mode: None,
1116            mutate: HookMutationConfig::default(),
1117            extra: HashMap::new(),
1118        }
1119    }
1120
1121    #[cfg(unix)]
1122    fn mark_executable(path: &std::path::Path) {
1123        use std::os::unix::fs::PermissionsExt;
1124
1125        let mut permissions = std::fs::metadata(path).expect("metadata").permissions();
1126        permissions.set_mode(0o755);
1127        std::fs::set_permissions(path, permissions).expect("set executable bit");
1128    }
1129
1130    #[cfg(not(unix))]
1131    fn mark_executable(_path: &std::path::Path) {}
1132
1133    #[tokio::test]
1134    async fn report_counts_statuses() {
1135        let checks = vec![
1136            CheckResult::pass("a", "ok"),
1137            CheckResult::warn("b", "warn", "needs attention"),
1138            CheckResult::fail("c", "fail", "broken"),
1139        ];
1140
1141        let report = PreflightReport::from_results(checks);
1142
1143        assert_eq!(report.warnings, 1);
1144        assert_eq!(report.failures, 1);
1145        assert!(!report.passed);
1146    }
1147
1148    #[test]
1149    fn default_checks_include_hooks_check_name() {
1150        let runner = PreflightRunner::default_checks();
1151        let check_names = runner.check_names();
1152
1153        assert!(check_names.contains(&"hooks"));
1154    }
1155
1156    #[tokio::test]
1157    async fn hooks_check_skips_when_hooks_are_disabled() {
1158        let config = RalphConfig::default();
1159        let check = HooksValidationCheck;
1160
1161        let result = check.run(&config).await;
1162
1163        assert_eq!(result.status, CheckStatus::Pass);
1164        assert_eq!(result.name, "hooks");
1165        assert!(result.label.contains("skipping"));
1166    }
1167
1168    #[tokio::test]
1169    async fn hooks_check_passes_with_resolvable_executable_command() {
1170        let temp = tempfile::tempdir().expect("tempdir");
1171        let script_dir = temp.path().join("scripts/hooks");
1172        std::fs::create_dir_all(&script_dir).expect("create script directory");
1173
1174        let script_path = script_dir.join("env-guard.sh");
1175        std::fs::write(&script_path, "#!/usr/bin/env sh\nexit 0\n").expect("write script");
1176        mark_executable(&script_path);
1177
1178        let mut config = RalphConfig::default();
1179        config.core.workspace_root = temp.path().to_path_buf();
1180        config.hooks.enabled = true;
1181        config.hooks.events.insert(
1182            HookPhaseEvent::PreLoopStart,
1183            vec![hook_spec("env-guard", &["./scripts/hooks/env-guard.sh"])],
1184        );
1185
1186        let check = HooksValidationCheck;
1187        let result = check.run(&config).await;
1188
1189        assert_eq!(result.status, CheckStatus::Pass);
1190        assert!(result.label.contains("Hooks validation passed"));
1191        assert!(result.label.contains("1 hook(s)"));
1192        assert!(result.message.is_none());
1193    }
1194
1195    #[tokio::test]
1196    async fn hooks_check_fails_with_actionable_duplicate_and_command_diagnostics() {
1197        let temp = tempfile::tempdir().expect("tempdir");
1198
1199        let mut config = RalphConfig::default();
1200        config.core.workspace_root = temp.path().to_path_buf();
1201        config.hooks.enabled = true;
1202        config.hooks.events.insert(
1203            HookPhaseEvent::PreLoopStart,
1204            vec![
1205                hook_spec("dup-hook", &["./scripts/hooks/missing-one.sh"]),
1206                hook_spec("dup-hook", &["./scripts/hooks/missing-two.sh"]),
1207            ],
1208        );
1209
1210        let check = HooksValidationCheck;
1211        let result = check.run(&config).await;
1212
1213        assert_eq!(result.status, CheckStatus::Fail);
1214        assert!(result.label.contains("Hooks validation failed"));
1215        let message = result.message.expect("expected failure diagnostics");
1216        assert!(message.contains("duplicate hook name 'dup-hook'"));
1217        assert!(message.contains("file does not exist"));
1218        assert!(message.contains("Fix: ensure command exists and is executable"));
1219    }
1220
1221    #[tokio::test]
1222    async fn run_selected_can_skip_hooks_check_failures() {
1223        let temp = tempfile::tempdir().expect("tempdir");
1224
1225        let mut config = RalphConfig::default();
1226        config.core.workspace_root = temp.path().to_path_buf();
1227        config.hooks.enabled = true;
1228        config.hooks.events.insert(
1229            HookPhaseEvent::PreLoopStart,
1230            vec![hook_spec("broken-hook", &["./scripts/hooks/missing.sh"])],
1231        );
1232
1233        let runner = PreflightRunner::default_checks();
1234        let report = runner.run_selected(&config, &["config".to_string()]).await;
1235
1236        assert!(report.passed);
1237        assert_eq!(report.failures, 0);
1238        assert_eq!(report.checks.len(), 1);
1239        assert_eq!(report.checks[0].name, "config");
1240    }
1241
1242    #[tokio::test]
1243    async fn config_check_emits_warning_details() {
1244        let mut config = RalphConfig::default();
1245        config.archive_prompts = true;
1246
1247        let check = ConfigValidCheck;
1248        let result = check.run(&config).await;
1249
1250        assert_eq!(result.status, CheckStatus::Warn);
1251        let message = result.message.expect("expected warning message");
1252        assert!(message.contains("archive_prompts"));
1253    }
1254
1255    #[tokio::test]
1256    async fn tools_check_reports_missing_tools() {
1257        let temp = tempfile::tempdir().expect("tempdir");
1258        std::fs::create_dir_all(temp.path().join(".git")).expect("create .git");
1259        let mut config = RalphConfig::default();
1260        config.core.workspace_root = temp.path().to_path_buf();
1261        let check = ToolsInPathCheck::new(vec!["definitely-not-a-tool".to_string()]);
1262
1263        let result = check.run(&config).await;
1264
1265        assert_eq!(result.status, CheckStatus::Fail);
1266        assert!(result.message.unwrap_or_default().contains("Missing"));
1267    }
1268
1269    #[tokio::test]
1270    async fn tools_check_warns_on_missing_optional_tools() {
1271        let temp = tempfile::tempdir().expect("tempdir");
1272        std::fs::create_dir_all(temp.path().join(".git")).expect("create .git");
1273        let mut config = RalphConfig::default();
1274        config.core.workspace_root = temp.path().to_path_buf();
1275        let check = ToolsInPathCheck::new_with_optional(
1276            Vec::new(),
1277            vec!["definitely-not-a-tool".to_string()],
1278        );
1279
1280        let result = check.run(&config).await;
1281
1282        assert_eq!(result.status, CheckStatus::Warn);
1283        assert!(result.message.unwrap_or_default().contains("Missing"));
1284    }
1285
1286    #[tokio::test]
1287    async fn paths_check_creates_missing_dirs() {
1288        let temp = tempfile::tempdir().expect("tempdir");
1289        let root = temp.path().to_path_buf();
1290
1291        let mut config = RalphConfig::default();
1292        config.core.workspace_root = root.clone();
1293        config.core.scratchpad.path = "nested/scratchpad.md".to_string();
1294        config.core.specs_dir = "nested/specs".to_string();
1295
1296        let check = PathsExistCheck;
1297        let result = check.run(&config).await;
1298
1299        assert!(root.join("nested").exists());
1300        assert!(root.join("nested/specs").exists());
1301        assert_eq!(result.status, CheckStatus::Warn);
1302    }
1303
1304    #[tokio::test]
1305    async fn telegram_check_skips_when_disabled() {
1306        let config = RalphConfig::default();
1307        let check = TelegramTokenCheck;
1308
1309        let result = check.run(&config).await;
1310
1311        assert_eq!(result.status, CheckStatus::Pass);
1312        assert!(result.label.contains("skipping"));
1313    }
1314
1315    #[tokio::test]
1316    async fn telegram_check_skips_in_web_mode() {
1317        let mut config = RalphConfig::default();
1318        config.robot.enabled = true;
1319        config.robot.mode = crate::config::RobotMode::Web;
1320        config.robot.timeout_seconds = Some(0);
1321        let check = TelegramTokenCheck;
1322
1323        let result = check.run(&config).await;
1324
1325        assert_eq!(result.status, CheckStatus::Pass);
1326        assert!(result.label.contains("web mode"));
1327    }
1328
1329    #[tokio::test]
1330    async fn git_check_skips_outside_repo() {
1331        let temp = tempfile::tempdir().expect("tempdir");
1332        let mut config = RalphConfig::default();
1333        config.core.workspace_root = temp.path().to_path_buf();
1334
1335        let check = GitCleanCheck;
1336        let result = check.run(&config).await;
1337
1338        assert_eq!(result.status, CheckStatus::Pass);
1339        assert!(result.label.contains("skipping"));
1340    }
1341
1342    #[tokio::test]
1343    async fn tools_check_skips_outside_repo() {
1344        let temp = tempfile::tempdir().expect("tempdir");
1345        let mut config = RalphConfig::default();
1346        config.core.workspace_root = temp.path().to_path_buf();
1347
1348        let check = ToolsInPathCheck::new(vec!["definitely-not-a-tool".to_string()]);
1349        let result = check.run(&config).await;
1350
1351        assert_eq!(result.status, CheckStatus::Pass);
1352        assert!(result.label.contains("skipping"));
1353    }
1354
1355    #[tokio::test]
1356    async fn specs_check_skips_when_no_directory() {
1357        let temp = tempfile::tempdir().expect("tempdir");
1358        let mut config = RalphConfig::default();
1359        config.core.workspace_root = temp.path().to_path_buf();
1360        config.core.specs_dir = "nonexistent/specs".to_string();
1361
1362        let check = SpecCompletenessCheck;
1363        let result = check.run(&config).await;
1364
1365        assert_eq!(result.status, CheckStatus::Pass);
1366        assert!(result.label.contains("skipping"));
1367    }
1368
1369    #[tokio::test]
1370    async fn specs_check_skips_when_empty_directory() {
1371        let temp = tempfile::tempdir().expect("tempdir");
1372        std::fs::create_dir_all(temp.path().join("specs")).expect("create specs dir");
1373        let mut config = RalphConfig::default();
1374        config.core.workspace_root = temp.path().to_path_buf();
1375        config.core.specs_dir = "specs".to_string();
1376
1377        let check = SpecCompletenessCheck;
1378        let result = check.run(&config).await;
1379
1380        assert_eq!(result.status, CheckStatus::Pass);
1381        assert!(result.label.contains("skipping"));
1382    }
1383
1384    #[tokio::test]
1385    async fn specs_check_passes_with_complete_spec() {
1386        let temp = tempfile::tempdir().expect("tempdir");
1387        let specs_dir = temp.path().join("specs");
1388        std::fs::create_dir_all(&specs_dir).expect("create specs dir");
1389        std::fs::write(
1390            specs_dir.join("feature.spec.md"),
1391            r"---
1392status: draft
1393---
1394
1395# Feature Spec
1396
1397## Goal
1398
1399Add a new feature.
1400
1401## Acceptance Criteria
1402
1403**Given** the system is running
1404**When** the user triggers the feature
1405**Then** the expected output is produced
1406",
1407        )
1408        .expect("write spec");
1409
1410        let mut config = RalphConfig::default();
1411        config.core.workspace_root = temp.path().to_path_buf();
1412        config.core.specs_dir = "specs".to_string();
1413
1414        let check = SpecCompletenessCheck;
1415        let result = check.run(&config).await;
1416
1417        assert_eq!(result.status, CheckStatus::Pass);
1418        assert!(result.label.contains("1 spec(s) valid"));
1419    }
1420
1421    #[tokio::test]
1422    async fn specs_check_warns_on_missing_acceptance_criteria() {
1423        let temp = tempfile::tempdir().expect("tempdir");
1424        let specs_dir = temp.path().join("specs");
1425        std::fs::create_dir_all(&specs_dir).expect("create specs dir");
1426        std::fs::write(
1427            specs_dir.join("incomplete.spec.md"),
1428            r"---
1429status: draft
1430---
1431
1432# Incomplete Spec
1433
1434## Goal
1435
1436Do something.
1437
1438## Requirements
1439
14401. Some requirement
1441",
1442        )
1443        .expect("write spec");
1444
1445        let mut config = RalphConfig::default();
1446        config.core.workspace_root = temp.path().to_path_buf();
1447        config.core.specs_dir = "specs".to_string();
1448
1449        let check = SpecCompletenessCheck;
1450        let result = check.run(&config).await;
1451
1452        assert_eq!(result.status, CheckStatus::Warn);
1453        assert!(result.label.contains("missing acceptance criteria"));
1454        let message = result.message.expect("expected message");
1455        assert!(message.contains("incomplete.spec.md"));
1456    }
1457
1458    #[tokio::test]
1459    async fn specs_check_skips_implemented_specs() {
1460        let temp = tempfile::tempdir().expect("tempdir");
1461        let specs_dir = temp.path().join("specs");
1462        std::fs::create_dir_all(&specs_dir).expect("create specs dir");
1463        // This spec lacks acceptance criteria but is already implemented
1464        std::fs::write(
1465            specs_dir.join("done.spec.md"),
1466            r"---
1467status: implemented
1468---
1469
1470# Done Spec
1471
1472## Goal
1473
1474Already done.
1475",
1476        )
1477        .expect("write spec");
1478
1479        let mut config = RalphConfig::default();
1480        config.core.workspace_root = temp.path().to_path_buf();
1481        config.core.specs_dir = "specs".to_string();
1482
1483        let check = SpecCompletenessCheck;
1484        let result = check.run(&config).await;
1485
1486        assert_eq!(result.status, CheckStatus::Pass);
1487    }
1488
1489    #[tokio::test]
1490    async fn specs_check_finds_specs_in_subdirectories() {
1491        let temp = tempfile::tempdir().expect("tempdir");
1492        let specs_dir = temp.path().join("specs");
1493        let sub_dir = specs_dir.join("adapters");
1494        std::fs::create_dir_all(&sub_dir).expect("create subdirectory");
1495        std::fs::write(
1496            sub_dir.join("adapter.spec.md"),
1497            r"---
1498status: draft
1499---
1500
1501# Adapter Spec
1502
1503## Acceptance Criteria
1504
1505- **Given** an adapter is configured
1506- **When** a request is sent
1507- **Then** the adapter responds correctly
1508",
1509        )
1510        .expect("write spec");
1511
1512        let mut config = RalphConfig::default();
1513        config.core.workspace_root = temp.path().to_path_buf();
1514        config.core.specs_dir = "specs".to_string();
1515
1516        let check = SpecCompletenessCheck;
1517        let result = check.run(&config).await;
1518
1519        assert_eq!(result.status, CheckStatus::Pass);
1520        assert!(result.label.contains("1 spec(s) valid"));
1521    }
1522
1523    #[test]
1524    fn has_acceptance_criteria_detects_bold_format() {
1525        let content = r"
1526## Acceptance Criteria
1527
1528**Given** the system is ready
1529**When** the user clicks
1530**Then** the result appears
1531";
1532        assert!(has_acceptance_criteria(content));
1533    }
1534
1535    #[test]
1536    fn has_acceptance_criteria_detects_list_format() {
1537        let content = r"
1538## Acceptance Criteria
1539
1540- Given the system is ready
1541- When the user clicks
1542- Then the result appears
1543";
1544        assert!(has_acceptance_criteria(content));
1545    }
1546
1547    #[test]
1548    fn has_acceptance_criteria_detects_bold_list_format() {
1549        let content = r"
1550## Acceptance Criteria
1551
1552- **Given** the system is ready
1553- **When** the user clicks
1554- **Then** the result appears
1555";
1556        assert!(has_acceptance_criteria(content));
1557    }
1558
1559    #[test]
1560    fn has_acceptance_criteria_requires_given_and_then() {
1561        // Only has Given, missing When and Then
1562        let content = "**Given** something\n";
1563        assert!(!has_acceptance_criteria(content));
1564
1565        // Has Given and Then (When implicit) — should pass
1566        let content = "**Given** something\n**Then** result\n";
1567        assert!(has_acceptance_criteria(content));
1568    }
1569
1570    #[test]
1571    fn has_acceptance_criteria_rejects_content_without_criteria() {
1572        let content = r"
1573# Some Spec
1574
1575## Goal
1576
1577Build something.
1578
1579## Requirements
1580
15811. It should work.
1582";
1583        assert!(!has_acceptance_criteria(content));
1584    }
1585
1586    // --- extract_acceptance_criteria tests ---
1587
1588    #[test]
1589    fn extract_criteria_bold_format() {
1590        let content = r#"
1591## Acceptance Criteria
1592
1593**Given** `backend: "amp"` in config
1594**When** Ralph executes an iteration
1595**Then** both flags are included
1596"#;
1597        let criteria = extract_acceptance_criteria(content);
1598        assert_eq!(criteria.len(), 1);
1599        assert_eq!(criteria[0].given, "`backend: \"amp\"` in config");
1600        assert_eq!(
1601            criteria[0].when.as_deref(),
1602            Some("Ralph executes an iteration")
1603        );
1604        assert_eq!(criteria[0].then, "both flags are included");
1605    }
1606
1607    #[test]
1608    fn extract_criteria_multiple_triples() {
1609        let content = r"
1610**Given** system A is running
1611**When** user clicks button
1612**Then** dialog appears
1613
1614**Given** dialog is open
1615**When** user confirms
1616**Then** action completes
1617";
1618        let criteria = extract_acceptance_criteria(content);
1619        assert_eq!(criteria.len(), 2);
1620        assert_eq!(criteria[0].given, "system A is running");
1621        assert_eq!(criteria[1].given, "dialog is open");
1622        assert_eq!(criteria[1].then, "action completes");
1623    }
1624
1625    #[test]
1626    fn extract_criteria_list_format() {
1627        let content = r"
1628## Acceptance Criteria
1629
1630- **Given** an adapter is configured
1631- **When** a request is sent
1632- **Then** the adapter responds correctly
1633";
1634        let criteria = extract_acceptance_criteria(content);
1635        assert_eq!(criteria.len(), 1);
1636        assert_eq!(criteria[0].given, "an adapter is configured");
1637        assert_eq!(criteria[0].when.as_deref(), Some("a request is sent"));
1638        assert_eq!(criteria[0].then, "the adapter responds correctly");
1639    }
1640
1641    #[test]
1642    fn extract_criteria_plain_text_format() {
1643        let content = r"
1644Given the server is started
1645When a GET request is sent
1646Then a 200 response is returned
1647";
1648        let criteria = extract_acceptance_criteria(content);
1649        assert_eq!(criteria.len(), 1);
1650        assert_eq!(criteria[0].given, "the server is started");
1651        assert_eq!(criteria[0].when.as_deref(), Some("a GET request is sent"));
1652        assert_eq!(criteria[0].then, "a 200 response is returned");
1653    }
1654
1655    #[test]
1656    fn extract_criteria_given_then_without_when() {
1657        let content = r"
1658**Given** the config is empty
1659**Then** defaults are used
1660";
1661        let criteria = extract_acceptance_criteria(content);
1662        assert_eq!(criteria.len(), 1);
1663        assert_eq!(criteria[0].given, "the config is empty");
1664        assert!(criteria[0].when.is_none());
1665        assert_eq!(criteria[0].then, "defaults are used");
1666    }
1667
1668    #[test]
1669    fn extract_criteria_empty_content() {
1670        let criteria = extract_acceptance_criteria("");
1671        assert!(criteria.is_empty());
1672    }
1673
1674    #[test]
1675    fn extract_criteria_no_criteria() {
1676        let content = r"
1677# Spec
1678
1679## Goal
1680
1681Build something.
1682";
1683        let criteria = extract_acceptance_criteria(content);
1684        assert!(criteria.is_empty());
1685    }
1686
1687    #[test]
1688    fn extract_criteria_incomplete_given_without_then_is_dropped() {
1689        let content = r"
1690**Given** orphan precondition
1691
1692Some other text here.
1693";
1694        let criteria = extract_acceptance_criteria(content);
1695        assert!(criteria.is_empty());
1696    }
1697
1698    #[test]
1699    fn extract_criteria_from_file_skips_implemented() {
1700        let temp = tempfile::tempdir().expect("tempdir");
1701        let path = temp.path().join("done.spec.md");
1702        std::fs::write(
1703            &path,
1704            r"---
1705status: implemented
1706---
1707
1708**Given** something
1709**When** something happens
1710**Then** result
1711",
1712        )
1713        .expect("write");
1714
1715        let criteria = extract_criteria_from_file(&path);
1716        assert!(criteria.is_empty());
1717    }
1718
1719    #[test]
1720    fn extract_criteria_from_file_returns_criteria() {
1721        let temp = tempfile::tempdir().expect("tempdir");
1722        let path = temp.path().join("feature.spec.md");
1723        std::fs::write(
1724            &path,
1725            r"---
1726status: draft
1727---
1728
1729# Feature
1730
1731**Given** the system is ready
1732**When** user acts
1733**Then** feature works
1734",
1735        )
1736        .expect("write");
1737
1738        let criteria = extract_criteria_from_file(&path);
1739        assert_eq!(criteria.len(), 1);
1740        assert_eq!(criteria[0].given, "the system is ready");
1741    }
1742
1743    #[test]
1744    fn extract_all_criteria_collects_from_directory() {
1745        let temp = tempfile::tempdir().expect("tempdir");
1746        let specs_dir = temp.path().join("specs");
1747        std::fs::create_dir_all(&specs_dir).expect("create dir");
1748
1749        std::fs::write(
1750            specs_dir.join("a.spec.md"),
1751            "**Given** A\n**When** B\n**Then** C\n",
1752        )
1753        .expect("write a");
1754
1755        std::fs::write(specs_dir.join("b.spec.md"), "**Given** X\n**Then** Y\n").expect("write b");
1756
1757        // Implemented spec should be excluded
1758        std::fs::write(
1759            specs_dir.join("c.spec.md"),
1760            "---\nstatus: implemented\n---\n**Given** skip\n**Then** skip\n",
1761        )
1762        .expect("write c");
1763
1764        let results = extract_all_criteria(&specs_dir).expect("extract");
1765        assert_eq!(results.len(), 2);
1766
1767        let filenames: Vec<&str> = results.iter().map(|(f, _)| f.as_str()).collect();
1768        assert!(filenames.contains(&"a.spec.md"));
1769        assert!(filenames.contains(&"b.spec.md"));
1770    }
1771
1772    #[test]
1773    fn match_clause_extracts_text() {
1774        assert_eq!(
1775            match_clause("**given** the system", "**Given** the system", "given"),
1776            Some("the system".to_string())
1777        );
1778        assert_eq!(
1779            match_clause("- **when** user clicks", "- **When** user clicks", "when"),
1780            Some("user clicks".to_string())
1781        );
1782        assert_eq!(
1783            match_clause("then result", "Then result", "then"),
1784            Some("result".to_string())
1785        );
1786        assert_eq!(
1787            match_clause("- given something", "- Given something", "given"),
1788            Some("something".to_string())
1789        );
1790        assert_eq!(
1791            match_clause("no match here", "No match here", "given"),
1792            None
1793        );
1794    }
1795}