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