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