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