1use anyhow::{bail, Context, Result};
22use chrono::Utc;
23use serde::{Deserialize, Serialize};
24use std::fmt;
25use std::fs;
26use std::path::{Path, PathBuf};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum OraclePhase {
34 LoadContext,
36 Frame,
38 Evaluate,
40 Rule,
42 Document,
44 Done,
46}
47
48impl Default for OraclePhase {
49 fn default() -> Self {
50 OraclePhase::LoadContext
51 }
52}
53
54impl fmt::Display for OraclePhase {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 match self {
57 OraclePhase::LoadContext => write!(f, "Load Context"),
58 OraclePhase::Frame => write!(f, "Frame"),
59 OraclePhase::Evaluate => write!(f, "Evaluate"),
60 OraclePhase::Rule => write!(f, "Rule"),
61 OraclePhase::Document => write!(f, "Document"),
62 OraclePhase::Done => write!(f, "Done"),
63 }
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct Criterion {
72 pub name: String,
74 pub description: String,
76 pub weight: u8,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct DecisionOption {
83 pub name: String,
85 pub description: String,
87 pub pros: Vec<String>,
89 pub cons: Vec<String>,
91 pub scores: Vec<(String, u8)>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub total_score: Option<f64>,
96}
97
98impl DecisionOption {
99 pub fn calculate_score(&mut self, criteria: &[Criterion]) -> f64 {
101 let mut total = 0.0;
102 let mut max_possible = 0.0;
103
104 for criterion in criteria {
105 let score = self
106 .scores
107 .iter()
108 .find(|(name, _)| name == &criterion.name)
109 .map(|(_, s)| *s as f64)
110 .unwrap_or(0.0);
111
112 total += score * criterion.weight as f64;
113 max_possible += 5.0 * criterion.weight as f64;
114 }
115
116 let normalized = if max_possible > 0.0 {
117 (total / max_possible) * 100.0
118 } else {
119 0.0
120 };
121
122 self.total_score = Some(normalized);
123 normalized
124 }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct Ruling {
130 pub chosen: String,
132 pub rationale: String,
134 pub trade_offs: Vec<String>,
136 #[serde(default)]
138 pub reversibility_conditions: Vec<String>,
139 pub confidence: Confidence,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146pub enum Confidence {
147 Low,
149 Medium,
151 High,
153}
154
155impl fmt::Display for Confidence {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 match self {
158 Confidence::Low => write!(f, "low"),
159 Confidence::Medium => write!(f, "medium"),
160 Confidence::High => write!(f, "high"),
161 }
162 }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct DecisionContext {
170 pub question: String,
172 pub codebase_context: String,
174 pub constraints: Vec<String>,
176 pub impact_areas: Vec<String>,
178 #[serde(default)]
180 pub related_decisions: Vec<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct DecisionRecord {
188 pub number: u32,
190 pub title: String,
192 pub created_at: String,
194 pub status: DecisionStatus,
196 pub context: DecisionContext,
198 pub criteria: Vec<Criterion>,
200 pub options: Vec<DecisionOption>,
202 pub ruling: Ruling,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum DecisionStatus {
210 Proposed,
212 Accepted,
214 Superseded,
216 Deprecated,
218}
219
220impl fmt::Display for DecisionStatus {
221 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222 match self {
223 DecisionStatus::Proposed => write!(f, "Proposed"),
224 DecisionStatus::Accepted => write!(f, "Accepted"),
225 DecisionStatus::Superseded => write!(f, "Superseded"),
226 DecisionStatus::Deprecated => write!(f, "Deprecated"),
227 }
228 }
229}
230
231impl DecisionRecord {
232 pub fn render_markdown(&self) -> String {
234 let mut md = String::with_capacity(4096);
235
236 md.push_str(&format!("# ADR-{:04}: {}\n\n", self.number, self.title));
238 md.push_str(&format!(
239 "> Date: {} | Status: {} | Confidence: {}\n\n",
240 &self.created_at[..10],
241 self.status,
242 self.ruling.confidence,
243 ));
244
245 md.push_str("## Context\n\n");
247 md.push_str(&self.context.question);
248 md.push_str("\n\n");
249
250 if !self.context.constraints.is_empty() {
251 md.push_str("**Constraints:**\n");
252 for c in &self.context.constraints {
253 md.push_str(&format!("- {}\n", c));
254 }
255 md.push('\n');
256 }
257
258 if !self.context.impact_areas.is_empty() {
259 md.push_str("**Impact Areas:**\n");
260 for area in &self.context.impact_areas {
261 md.push_str(&format!("- {}\n", area));
262 }
263 md.push('\n');
264 }
265
266 if !self.context.codebase_context.is_empty() {
267 md.push_str("### Codebase Context\n\n");
268 md.push_str(&self.context.codebase_context);
269 md.push_str("\n\n");
270 }
271
272 if !self.criteria.is_empty() {
274 md.push_str("## Decision Criteria\n\n");
275 md.push_str("| Criterion | Description | Weight |\n");
276 md.push_str("|-----------|-------------|--------|\n");
277 for criterion in &self.criteria {
278 md.push_str(&format!(
279 "| {} | {} | {} |\n",
280 criterion.name, criterion.description, criterion.weight
281 ));
282 }
283 md.push('\n');
284 }
285
286 if !self.options.is_empty() {
288 md.push_str("## Options Considered\n\n");
289
290 md.push_str("| Option | ");
292 for criterion in &self.criteria {
293 md.push_str(&format!("{} | ", criterion.name));
294 }
295 md.push_str("Score |\n");
296
297 md.push_str("|--------|");
298 for _ in &self.criteria {
299 md.push_str("---|");
300 }
301 md.push_str("------|\n");
302
303 for option in &self.options {
304 md.push_str(&format!("| {} | ", option.name));
305 for criterion in &self.criteria {
306 let score = option
307 .scores
308 .iter()
309 .find(|(name, _)| name == &criterion.name)
310 .map(|(_, s)| s.to_string())
311 .unwrap_or_else(|| "-".to_string());
312 md.push_str(&format!("{} | ", score));
313 }
314 let score_str = option
315 .total_score
316 .map(|s| format!("{:.0}/100", s))
317 .unwrap_or_else(|| "-".to_string());
318 md.push_str(&format!("{} |\n", score_str));
319 }
320 md.push('\n');
321
322 for option in &self.options {
324 let chosen_marker = if option.name == self.ruling.chosen {
325 " ✅ **(chosen)**"
326 } else {
327 ""
328 };
329 md.push_str(&format!("### {}{}\n\n", option.name, chosen_marker));
330 md.push_str(&option.description);
331 md.push_str("\n\n");
332
333 if !option.pros.is_empty() {
334 md.push_str("**Pros:**\n");
335 for pro in &option.pros {
336 md.push_str(&format!("+ {}\n", pro));
337 }
338 md.push('\n');
339 }
340
341 if !option.cons.is_empty() {
342 md.push_str("**Cons:**\n");
343 for con in &option.cons {
344 md.push_str(&format!("- {}\n", con));
345 }
346 md.push('\n');
347 }
348 }
349 }
350
351 md.push_str("## Decision\n\n");
353 md.push_str(&format!("**{}**\n\n", self.ruling.chosen));
354 md.push_str(&self.ruling.rationale);
355 md.push_str("\n\n");
356
357 if !self.ruling.trade_offs.is_empty() {
358 md.push_str("### Trade-offs Accepted\n\n");
359 for trade_off in &self.ruling.trade_offs {
360 md.push_str(&format!("- {}\n", trade_off));
361 }
362 md.push('\n');
363 }
364
365 if !self.ruling.reversibility_conditions.is_empty() {
366 md.push_str("### Reversibility Conditions\n\n");
367 md.push_str("This decision should be revisited if:\n\n");
368 for condition in &self.ruling.reversibility_conditions {
369 md.push_str(&format!("- {}\n", condition));
370 }
371 md.push('\n');
372 }
373
374 if !self.context.related_decisions.is_empty() {
376 md.push_str("## Related Decisions\n\n");
377 for related in &self.context.related_decisions {
378 md.push_str(&format!("- {}\n", related));
379 }
380 md.push('\n');
381 }
382
383 md
384 }
385
386 pub fn write_to_file(&self, dir: &Path) -> Result<PathBuf> {
388 fs::create_dir_all(dir)
389 .with_context(|| format!("Failed to create {}", dir.display()))?;
390
391 let slug = slugify(&self.title);
392 let filename = format!("ADR-{:04}-{}.md", self.number, slug);
393 let path = dir.join(&filename);
394
395 let content = self.render_markdown();
396 fs::write(&path, &content)
397 .with_context(|| format!("Failed to write ADR to {}", path.display()))?;
398
399 Ok(path)
400 }
401
402 pub fn next_number(dir: &Path) -> u32 {
404 if !dir.exists() {
405 return 1;
406 }
407
408 let mut max: u32 = 0;
409 if let Ok(entries) = fs::read_dir(dir) {
410 for entry in entries.flatten() {
411 let name = entry.file_name().to_string_lossy().to_string();
412 if let Some(rest) = name.strip_prefix("ADR-") {
413 if let Some(num_str) = rest.split('-').next() {
414 if let Ok(num) = num_str.parse::<u32>() {
415 max = max.max(num);
416 }
417 }
418 }
419 }
420 }
421
422 max + 1
423 }
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct OracleSession {
431 pub phase: OraclePhase,
433 pub question: String,
435 #[serde(skip_serializing_if = "Option::is_none")]
437 pub project_root: Option<PathBuf>,
438 #[serde(skip_serializing_if = "Option::is_none")]
440 pub context: Option<DecisionContext>,
441 pub criteria: Vec<Criterion>,
443 pub options: Vec<DecisionOption>,
445 #[serde(skip_serializing_if = "Option::is_none")]
447 pub ruling: Option<Ruling>,
448 #[serde(skip_serializing_if = "Option::is_none")]
450 pub record: Option<DecisionRecord>,
451}
452
453impl OracleSession {
454 pub fn new(question: impl Into<String>) -> Self {
456 Self {
457 phase: OraclePhase::LoadContext,
458 question: question.into(),
459 project_root: None,
460 context: None,
461 criteria: Vec::new(),
462 options: Vec::new(),
463 ruling: None,
464 record: None,
465 }
466 }
467
468 pub fn with_project_root(mut self, root: impl Into<PathBuf>) -> Self {
470 self.project_root = Some(root.into());
471 self
472 }
473
474 pub fn advance(&mut self) -> Result<()> {
476 let next = match self.phase {
477 OraclePhase::LoadContext => OraclePhase::Frame,
478 OraclePhase::Frame => OraclePhase::Evaluate,
479 OraclePhase::Evaluate => OraclePhase::Rule,
480 OraclePhase::Rule => OraclePhase::Document,
481 OraclePhase::Document => OraclePhase::Done,
482 OraclePhase::Done => bail!("Cannot advance past Done"),
483 };
484 self.phase = next;
485 Ok(())
486 }
487
488 pub fn set_phase(&mut self, phase: OraclePhase) {
490 self.phase = phase;
491 }
492
493 pub fn set_context(&mut self, ctx: DecisionContext) {
495 self.context = Some(ctx);
496 }
497
498 pub fn add_criterion(&mut self, name: impl Into<String>, description: impl Into<String>, weight: u8) {
500 self.criteria.push(Criterion {
501 name: name.into(),
502 description: description.into(),
503 weight,
504 });
505 }
506
507 pub fn add_option(&mut self, option: DecisionOption) {
509 self.options.push(option);
510 }
511
512 pub fn option_count(&self) -> usize {
514 self.options.len()
515 }
516
517 pub fn score_options(&mut self) {
519 let criteria = self.criteria.clone();
520 for option in &mut self.options {
521 option.calculate_score(&criteria);
522 }
523 }
524
525 pub fn set_ruling(&mut self, ruling: Ruling) {
527 self.ruling = Some(ruling);
528 }
529
530 pub fn finalize(&mut self, status: DecisionStatus) -> Result<()> {
532 let ctx = self.context.clone()
533 .context("Decision context not set")?;
534 let ruling = self.ruling.clone()
535 .context("Ruling not set — call set_ruling() first")?;
536
537 let number = if let Some(ref root) = self.project_root {
539 let adr_dir = root.join("docs").join("decisions");
540 DecisionRecord::next_number(&adr_dir)
541 } else {
542 1
543 };
544
545 let record = DecisionRecord {
546 number,
547 title: self.question.clone(),
548 created_at: Utc::now().to_rfc3339(),
549 status,
550 context: ctx,
551 criteria: self.criteria.clone(),
552 options: self.options.clone(),
553 ruling,
554 };
555
556 self.record = Some(record);
557 Ok(())
558 }
559
560 pub fn write_record(&self, explicit_path: Option<&Path>) -> Result<PathBuf> {
562 let record = self.record.as_ref()
563 .context("Record not finalized — call finalize() first")?;
564
565 if let Some(path) = explicit_path {
566 if let Some(parent) = path.parent() {
567 fs::create_dir_all(parent)
568 .with_context(|| format!("Failed to create {}", parent.display()))?;
569 }
570 let content = record.render_markdown();
571 fs::write(path, &content)
572 .with_context(|| format!("Failed to write ADR to {}", path.display()))?;
573 Ok(path.to_path_buf())
574 } else {
575 let root = self.project_root.as_deref()
576 .context("No project root and no explicit path")?;
577 let adr_dir = root.join("docs").join("decisions");
578 record.write_to_file(&adr_dir)
579 }
580 }
581}
582
583pub struct OracleSkill;
587
588impl OracleSkill {
589 pub fn new() -> Self {
591 Self
592 }
593
594 pub fn skill_prompt() -> String {
596 r#"# Oracle Skill
597
598You are running the **oracle** skill. You are the high-context decision
599maker, called when the implementing agent encounters uncertainty. You make
600clear, justified decisions quickly.
601
602## Workflow
603
604### Phase 1: Load Context
605
6061. Understand the question being asked.
6072. Read relevant code files to understand the current state.
6083. Identify what's already decided and what's genuinely uncertain.
6094. Don't over-gather — only read what's directly relevant.
610
611### Phase 2: Frame the Decision
612
6131. State the decision clearly in one sentence.
6142. List the constraints (what's NOT optional).
6153. List the options (usually 2–4 viable approaches).
6164. Identify who/what this decision affects.
617
618### Phase 3: Evaluate Options
619
6201. Define criteria weighted by project priorities:
621 - **Simplicity** — fewer moving parts, easier to understand
622 - **Correctness** — handles edge cases, doesn't introduce bugs
623 - **Performance** — meets performance requirements
624 - **Maintainability** — easy to change later
625 - **Consistency** — follows existing patterns in the codebase
626
6272. Score each option against each criterion (1–5).
6283. Calculate weighted scores.
6294. Don't over-optimize — a 5% score difference is noise.
630
631### Phase 4: Rule
632
6331. State the decision clearly: "We will X."
6342. Explain WHY — reference the scores and criteria.
6353. List trade-offs being accepted.
6364. State what conditions would change this decision.
637
638### Phase 5: Document
639
6401. Write the ADR to `docs/decisions/ADR-NNNN-<slug>.md`.
6412. Use the Architecture Decision Record format.
642
643## Rules
644
645- **Decide, don't deliberate.** You exist to break deadlocks, not to explore.
646- **Default to simplicity.** When options are close, pick the simpler one.
647- **Be specific.** "Use a HashMap" not "use a data structure."
648- **Consider reversibility.** Prefer decisions that are easy to reverse.
649- **Don't gold-plate.** Solve the problem at hand, not hypothetical future ones.
650- **One decision per session.** If there are multiple questions, handle them separately.
651- **Be honest about uncertainty.** Low confidence is fine — just say so.
652"#
653 .to_string()
654 }
655}
656
657impl Default for OracleSkill {
658 fn default() -> Self {
659 Self::new()
660 }
661}
662
663impl fmt::Debug for OracleSkill {
664 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
665 f.debug_struct("OracleSkill").finish()
666 }
667}
668
669fn slugify(s: &str) -> String {
672 s.to_lowercase()
673 .chars()
674 .map(|c| {
675 if c.is_ascii_alphanumeric() {
676 c
677 } else if c == ' ' || c == '_' || c == '-' {
678 '-'
679 } else {
680 '\0'
681 }
682 })
683 .filter(|c| *c != '\0')
684 .collect::<String>()
685 .trim_matches('-')
686 .to_string()
687}
688
689#[cfg(test)]
692mod tests {
693 use super::*;
694 use std::fs;
695
696 fn sample_option(name: &str) -> DecisionOption {
697 DecisionOption {
698 name: name.to_string(),
699 description: format!("{} approach", name),
700 pros: vec!["Simple".to_string()],
701 cons: vec!["Less flexible".to_string()],
702 scores: vec![
703 ("Simplicity".to_string(), 4),
704 ("Performance".to_string(), 3),
705 ],
706 total_score: None,
707 }
708 }
709
710 fn sample_criteria() -> Vec<Criterion> {
711 vec![
712 Criterion {
713 name: "Simplicity".to_string(),
714 description: "Fewer moving parts".to_string(),
715 weight: 3,
716 },
717 Criterion {
718 name: "Performance".to_string(),
719 description: "Meets perf requirements".to_string(),
720 weight: 2,
721 },
722 ]
723 }
724
725 #[test]
726 fn test_session_new() {
727 let session = OracleSession::new("Which DB to use?");
728 assert_eq!(session.phase, OraclePhase::LoadContext);
729 assert_eq!(session.question, "Which DB to use?");
730 assert!(session.criteria.is_empty());
731 assert!(session.options.is_empty());
732 }
733
734 #[test]
735 fn test_phase_advance() {
736 let mut session = OracleSession::new("test");
737 assert_eq!(session.phase, OraclePhase::LoadContext);
738
739 session.advance().unwrap();
740 assert_eq!(session.phase, OraclePhase::Frame);
741
742 session.advance().unwrap();
743 assert_eq!(session.phase, OraclePhase::Evaluate);
744
745 session.advance().unwrap();
746 assert_eq!(session.phase, OraclePhase::Rule);
747
748 session.advance().unwrap();
749 assert_eq!(session.phase, OraclePhase::Document);
750
751 session.advance().unwrap();
752 assert_eq!(session.phase, OraclePhase::Done);
753
754 assert!(session.advance().is_err());
755 }
756
757 #[test]
758 fn test_set_phase() {
759 let mut session = OracleSession::new("test");
760 session.set_phase(OraclePhase::Evaluate);
761 assert_eq!(session.phase, OraclePhase::Evaluate);
762 }
763
764 #[test]
765 fn test_phase_display() {
766 assert_eq!(format!("{}", OraclePhase::LoadContext), "Load Context");
767 assert_eq!(format!("{}", OraclePhase::Frame), "Frame");
768 assert_eq!(format!("{}", OraclePhase::Evaluate), "Evaluate");
769 assert_eq!(format!("{}", OraclePhase::Rule), "Rule");
770 assert_eq!(format!("{}", OraclePhase::Document), "Document");
771 assert_eq!(format!("{}", OraclePhase::Done), "Done");
772 }
773
774 #[test]
775 fn test_confidence_display() {
776 assert_eq!(format!("{}", Confidence::Low), "low");
777 assert_eq!(format!("{}", Confidence::Medium), "medium");
778 assert_eq!(format!("{}", Confidence::High), "high");
779 }
780
781 #[test]
782 fn test_decision_status_display() {
783 assert_eq!(format!("{}", DecisionStatus::Proposed), "Proposed");
784 assert_eq!(format!("{}", DecisionStatus::Accepted), "Accepted");
785 assert_eq!(format!("{}", DecisionStatus::Superseded), "Superseded");
786 assert_eq!(format!("{}", DecisionStatus::Deprecated), "Deprecated");
787 }
788
789 #[test]
790 fn test_add_criteria() {
791 let mut session = OracleSession::new("test");
792 session.add_criterion("Simplicity", "Fewer moving parts", 3);
793 session.add_criterion("Performance", "Fast enough", 2);
794
795 assert_eq!(session.criteria.len(), 2);
796 assert_eq!(session.criteria[0].name, "Simplicity");
797 assert_eq!(session.criteria[0].weight, 3);
798 }
799
800 #[test]
801 fn test_add_options() {
802 let mut session = OracleSession::new("test");
803 session.add_option(sample_option("HashMap"));
804 session.add_option(sample_option("BTreeMap"));
805
806 assert_eq!(session.option_count(), 2);
807 }
808
809 #[test]
810 fn test_score_options() {
811 let mut session = OracleSession::new("test");
812 session.criteria = sample_criteria();
813
814 let mut opt1 = sample_option("HashMap");
815 opt1.scores = vec![
816 ("Simplicity".to_string(), 5),
817 ("Performance".to_string(), 4),
818 ];
819
820 let mut opt2 = sample_option("BTreeMap");
821 opt2.scores = vec![
822 ("Simplicity".to_string(), 3),
823 ("Performance".to_string(), 5),
824 ];
825
826 session.add_option(opt1);
827 session.add_option(opt2);
828 session.score_options();
829
830 assert_eq!(session.options[0].total_score, Some(92.0));
832 assert_eq!(session.options[1].total_score, Some(76.0));
834 }
835
836 #[test]
837 fn test_score_options_empty_criteria() {
838 let mut session = OracleSession::new("test");
839 session.add_option(sample_option("A"));
840 session.score_options();
841 assert_eq!(session.options[0].total_score, Some(0.0));
842 }
843
844 #[test]
845 fn test_option_calculate_score() {
846 let criteria = sample_criteria();
847 let mut opt = DecisionOption {
848 name: "Test".to_string(),
849 description: "Test".to_string(),
850 pros: vec![],
851 cons: vec![],
852 scores: vec![
853 ("Simplicity".to_string(), 5),
854 ("Performance".to_string(), 5),
855 ],
856 total_score: None,
857 };
858
859 let score = opt.calculate_score(&criteria);
860 assert_eq!(score, 100.0);
862 assert_eq!(opt.total_score, Some(100.0));
863 }
864
865 #[test]
866 fn test_set_ruling() {
867 let mut session = OracleSession::new("test");
868 session.set_ruling(Ruling {
869 chosen: "HashMap".to_string(),
870 rationale: "Simpler and fast enough".to_string(),
871 trade_offs: vec!["No ordering".to_string()],
872 reversibility_conditions: vec!["If we need ordered iteration".to_string()],
873 confidence: Confidence::High,
874 });
875
876 assert!(session.ruling.is_some());
877 assert_eq!(session.ruling.as_ref().unwrap().chosen, "HashMap");
878 assert_eq!(session.ruling.as_ref().unwrap().confidence, Confidence::High);
879 }
880
881 #[test]
882 fn test_finalize_no_context() {
883 let mut session = OracleSession::new("test");
884 assert!(session.finalize(DecisionStatus::Accepted).is_err());
885 }
886
887 #[test]
888 fn test_finalize_no_ruling() {
889 let mut session = OracleSession::new("test");
890 session.set_context(DecisionContext {
891 question: "test".to_string(),
892 codebase_context: String::new(),
893 constraints: vec![],
894 impact_areas: vec![],
895 related_decisions: vec![],
896 });
897 assert!(session.finalize(DecisionStatus::Accepted).is_err());
898 }
899
900 #[test]
901 fn test_finalize_and_write() {
902 let tmp = tempfile::tempdir().unwrap();
903 let mut session = OracleSession::new("Which data structure for cache?")
904 .with_project_root(tmp.path());
905
906 session.set_context(DecisionContext {
907 question: "Which data structure for cache?".to_string(),
908 codebase_context: "Single-process CLI tool".to_string(),
909 constraints: vec!["Must be fast".to_string()],
910 impact_areas: vec!["src/cache.rs".to_string()],
911 related_decisions: vec![],
912 });
913
914 session.criteria = sample_criteria();
915 session.add_option(sample_option("HashMap"));
916 session.add_option(sample_option("BTreeMap"));
917 session.score_options();
918
919 session.set_ruling(Ruling {
920 chosen: "HashMap".to_string(),
921 rationale: "Simpler and O(1) lookup".to_string(),
922 trade_offs: vec!["No ordering guarantees".to_string()],
923 reversibility_conditions: vec!["If ordered iteration needed".to_string()],
924 confidence: Confidence::High,
925 });
926
927 session.finalize(DecisionStatus::Accepted).unwrap();
928
929 let record = session.record.as_ref().unwrap();
930 assert_eq!(record.number, 1);
931 assert_eq!(record.status, DecisionStatus::Accepted);
932 assert_eq!(record.ruling.chosen, "HashMap");
933
934 let path = session.write_record(None).unwrap();
936 assert!(path.exists());
937 assert!(path.to_string_lossy().contains("docs/decisions"));
938 assert!(path.to_string_lossy().contains("ADR-0001"));
939
940 let content = fs::read_to_string(&path).unwrap();
941 assert!(content.contains("# ADR-0001"));
942 assert!(content.contains("HashMap"));
943 assert!(content.contains("Accepted"));
944 }
945
946 #[test]
947 fn test_write_record_explicit_path() {
948 let tmp = tempfile::tempdir().unwrap();
949 let mut session = OracleSession::new("test");
950 session.set_context(DecisionContext {
951 question: "test".to_string(),
952 codebase_context: String::new(),
953 constraints: vec![],
954 impact_areas: vec![],
955 related_decisions: vec![],
956 });
957 session.set_ruling(Ruling {
958 chosen: "A".to_string(),
959 rationale: "Best".to_string(),
960 trade_offs: vec![],
961 reversibility_conditions: vec![],
962 confidence: Confidence::Medium,
963 });
964 session.finalize(DecisionStatus::Proposed).unwrap();
965
966 let explicit = tmp.path().join("decision.md");
967 let path = session.write_record(Some(&explicit)).unwrap();
968 assert_eq!(path, explicit);
969 assert!(path.exists());
970 }
971
972 #[test]
973 fn test_next_number_empty() {
974 let tmp = tempfile::tempdir().unwrap();
975 assert_eq!(DecisionRecord::next_number(tmp.path()), 1);
976 }
977
978 #[test]
979 fn test_next_number_with_existing() {
980 let tmp = tempfile::tempdir().unwrap();
981 fs::write(tmp.path().join("ADR-0001-test.md"), "").unwrap();
982 fs::write(tmp.path().join("ADR-0003-test.md"), "").unwrap();
983
984 assert_eq!(DecisionRecord::next_number(tmp.path()), 4);
985 }
986
987 #[test]
988 fn test_next_number_nonexistent_dir() {
989 assert_eq!(
990 DecisionRecord::next_number(Path::new("/nonexistent")),
991 1
992 );
993 }
994
995 #[test]
996 fn test_render_markdown() {
997 let record = DecisionRecord {
998 number: 7,
999 title: "Use SQLite for local storage".to_string(),
1000 created_at: "2025-06-15T10:30:00Z".to_string(),
1001 status: DecisionStatus::Accepted,
1002 context: DecisionContext {
1003 question: "Which embedded DB?".to_string(),
1004 codebase_context: "Desktop app, local data".to_string(),
1005 constraints: vec!["Single-file DB".to_string()],
1006 impact_areas: vec!["src/storage.rs".to_string()],
1007 related_decisions: vec!["ADR-0003".to_string()],
1008 },
1009 criteria: sample_criteria(),
1010 options: {
1011 let mut opt = sample_option("SQLite");
1012 opt.total_score = Some(90.0);
1013 vec![opt]
1014 },
1015 ruling: Ruling {
1016 chosen: "SQLite".to_string(),
1017 rationale: "Battle-tested, single-file, good perf".to_string(),
1018 trade_offs: vec!["Write concurrency limited".to_string()],
1019 reversibility_conditions: vec!["If we need multi-process writes".to_string()],
1020 confidence: Confidence::High,
1021 },
1022 };
1023
1024 let md = record.render_markdown();
1025 assert!(md.contains("# ADR-0007: Use SQLite for local storage"));
1026 assert!(md.contains("Status: Accepted"));
1027 assert!(md.contains("Confidence: high"));
1028 assert!(md.contains("## Context"));
1029 assert!(md.contains("Which embedded DB?"));
1030 assert!(md.contains("## Decision Criteria"));
1031 assert!(md.contains("| Simplicity |"));
1032 assert!(md.contains("## Options Considered"));
1033 assert!(md.contains("SQLite ✅ **(chosen)**"));
1034 assert!(md.contains("## Decision"));
1035 assert!(md.contains("Battle-tested"));
1036 assert!(md.contains("### Trade-offs Accepted"));
1037 assert!(md.contains("Write concurrency limited"));
1038 assert!(md.contains("### Reversibility Conditions"));
1039 assert!(md.contains("multi-process writes"));
1040 assert!(md.contains("## Related Decisions"));
1041 assert!(md.contains("ADR-0003"));
1042 }
1043
1044 #[test]
1045 fn test_session_serialization_roundtrip() {
1046 let mut session = OracleSession::new("Which cache strategy?");
1047 session.add_criterion("Simplicity", "Few parts", 3);
1048 session.add_option(sample_option("LRU"));
1049 session.set_phase(OraclePhase::Evaluate);
1050
1051 let json = serde_json::to_string(&session).unwrap();
1052 let parsed: OracleSession = serde_json::from_str(&json).unwrap();
1053 assert_eq!(parsed.question, "Which cache strategy?");
1054 assert_eq!(parsed.phase, OraclePhase::Evaluate);
1055 assert_eq!(parsed.criteria.len(), 1);
1056 assert_eq!(parsed.option_count(), 1);
1057 }
1058
1059 #[test]
1060 fn test_skill_prompt_not_empty() {
1061 let prompt = OracleSkill::skill_prompt();
1062 assert!(prompt.contains("Oracle Skill"));
1063 assert!(prompt.contains("Phase 1: Load Context"));
1064 assert!(prompt.contains("Phase 4: Rule"));
1065 }
1066
1067 #[test]
1068 fn test_slugify() {
1069 assert_eq!(slugify("Use SQLite for storage"), "use-sqlite-for-storage");
1070 assert_eq!(slugify("hello_world"), "hello-world");
1071 }
1072
1073 #[test]
1074 fn test_full_lifecycle() {
1075 let tmp = tempfile::tempdir().unwrap();
1076
1077 let adr_dir = tmp.path().join("docs").join("decisions");
1079 fs::create_dir_all(&adr_dir).unwrap();
1080 fs::write(adr_dir.join("ADR-0001-test.md"), "").unwrap();
1081
1082 let mut session = OracleSession::new("REST vs gRPC?")
1083 .with_project_root(tmp.path());
1084
1085 session.set_context(DecisionContext {
1087 question: "REST vs gRPC for internal service communication?".to_string(),
1088 codebase_context: "Microservices, Rust backend".to_string(),
1089 constraints: vec!["Must support streaming".to_string()],
1090 impact_areas: vec!["src/api/".to_string()],
1091 related_decisions: vec!["ADR-0001".to_string()],
1092 });
1093 session.advance().unwrap();
1094
1095 session.add_criterion("Simplicity", "Easy to debug", 3);
1097 session.add_criterion("Performance", "Low latency", 2);
1098 session.add_criterion("Ecosystem", "Tool support", 2);
1099
1100 let mut rest = DecisionOption {
1101 name: "REST".to_string(),
1102 description: "HTTP+JSON REST API".to_string(),
1103 pros: vec!["Ubiquitous".to_string(), "Easy to debug".to_string()],
1104 cons: vec!["No native streaming".to_string()],
1105 scores: vec![
1106 ("Simplicity".to_string(), 5),
1107 ("Performance".to_string(), 3),
1108 ("Ecosystem".to_string(), 5),
1109 ],
1110 total_score: None,
1111 };
1112
1113 let mut grpc = DecisionOption {
1114 name: "gRPC".to_string(),
1115 description: "gRPC with protobuf".to_string(),
1116 pros: vec!["Native streaming".to_string(), "Codegen".to_string()],
1117 cons: vec!["Complex setup".to_string(), "Harder to debug".to_string()],
1118 scores: vec![
1119 ("Simplicity".to_string(), 2),
1120 ("Performance".to_string(), 5),
1121 ("Ecosystem".to_string(), 3),
1122 ],
1123 total_score: None,
1124 };
1125
1126 session.advance().unwrap(); rest.calculate_score(&session.criteria);
1129 grpc.calculate_score(&session.criteria);
1130 session.add_option(rest);
1131 session.add_option(grpc);
1132
1133 session.advance().unwrap(); session.set_ruling(Ruling {
1136 chosen: "gRPC".to_string(),
1137 rationale: "Streaming requirement rules out plain REST".to_string(),
1138 trade_offs: vec!["More complex tooling".to_string()],
1139 reversibility_conditions: vec!["If streaming requirement is dropped".to_string()],
1140 confidence: Confidence::Medium,
1141 });
1142
1143 session.advance().unwrap(); session.finalize(DecisionStatus::Accepted).unwrap();
1146
1147 assert_eq!(session.record.as_ref().unwrap().number, 2);
1149
1150 let path = session.write_record(None).unwrap();
1151 assert!(path.exists());
1152 assert!(path.to_string_lossy().contains("ADR-0002"));
1153
1154 let content = fs::read_to_string(&path).unwrap();
1155 assert!(content.contains("gRPC"));
1156 assert!(content.contains("Streaming requirement"));
1157 }
1158}