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