Skip to main content

oxi/skills/
oracle.rs

1//! Oracle skill for oxi
2//!
3//! High-context decision oracle that provides authoritative answers when
4//! the agent encounters uncertainty during implementation. The oracle:
5//!
6//! 1. **Loads context** — reads the current state, pending question, and
7//!    relevant codebase context
8//! 2. **Identifies the decision frame** — what exactly needs to be decided,
9//!    what are the constraints, what are the options
10//! 3. **Evaluates options** — weighs trade-offs against project-specific
11//!    criteria (simplicity, performance, maintainability, etc.)
12//! 4. **Produces a ruling** — a clear, justified decision with rationale
13//! 5. **Documents** — records the decision for future reference
14//!
15//! The module provides:
16//! - [`OracleSession`] — state machine for the decision process
17//! - [`Decision`] — a structured decision with options, trade-offs, and ruling
18//! - [`DecisionRecord`] — an ADR-like document persisted to disk
19//! - [`OracleSkill`] — skill prompt generator
20
21use anyhow::{bail, Context, Result};
22use chrono::Utc;
23use serde::{Deserialize, Serialize};
24use std::fmt;
25use std::fs;
26use std::path::{Path, PathBuf};
27
28// ── Phase ──────────────────────────────────────────────────────────────
29
30/// The phase an oracle session is in.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum OraclePhase {
34    /// Loading relevant context from the codebase.
35    LoadContext,
36    /// Identifying the decision frame (what, constraints, options).
37    Frame,
38    /// Evaluating options against project criteria.
39    Evaluate,
40    /// Producing the ruling with rationale.
41    Rule,
42    /// Documenting the decision.
43    Document,
44    /// Decision complete.
45    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// ── Decision types ─────────────────────────────────────────────────────
68
69/// A decision criterion (e.g., "simplicity", "performance", "maintainability").
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct Criterion {
72    /// Criterion name.
73    pub name: String,
74    /// Description of what this criterion values.
75    pub description: String,
76    /// Weight of this criterion (higher = more important).
77    pub weight: u8,
78}
79
80/// An option being considered for the decision.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct DecisionOption {
83    /// Short name for this option.
84    pub name: String,
85    /// Detailed description.
86    pub description: String,
87    /// Pros of this option.
88    pub pros: Vec<String>,
89    /// Cons of this option.
90    pub cons: Vec<String>,
91    /// Scores against each criterion (criterion name → score 1-5).
92    pub scores: Vec<(String, u8)>,
93    /// Weighted total score.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub total_score: Option<f64>,
96}
97
98impl DecisionOption {
99    /// Calculate the weighted total score.
100    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/// The ruling — the oracle's final decision.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct Ruling {
130    /// The chosen option.
131    pub chosen: String,
132    /// Why this option was chosen.
133    pub rationale: String,
134    /// Key trade-offs accepted.
135    pub trade_offs: Vec<String>,
136    /// What conditions would change this decision.
137    #[serde(default)]
138    pub reversibility_conditions: Vec<String>,
139    /// Confidence level in this decision.
140    pub confidence: Confidence,
141}
142
143/// Confidence level of a ruling.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146pub enum Confidence {
147    /// Need more information — decision is tentative.
148    Low,
149    /// Reasonably confident but some uncertainty remains.
150    Medium,
151    /// Very confident — clear best option.
152    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// ── Decision context ───────────────────────────────────────────────────
166
167/// The context loaded for making the decision.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct DecisionContext {
170    /// What question or decision is needed.
171    pub question: String,
172    /// Relevant context from the codebase.
173    pub codebase_context: String,
174    /// Constraints on the decision.
175    pub constraints: Vec<String>,
176    /// What will be affected by this decision.
177    pub impact_areas: Vec<String>,
178    /// Related past decisions (ADR references).
179    #[serde(default)]
180    pub related_decisions: Vec<String>,
181}
182
183// ── Decision record ────────────────────────────────────────────────────
184
185/// An Architecture Decision Record (ADR) produced by the oracle.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct DecisionRecord {
188    /// ADR number (auto-incremented within the project).
189    pub number: u32,
190    /// Title of the decision.
191    pub title: String,
192    /// Creation timestamp.
193    pub created_at: String,
194    /// Status of the decision.
195    pub status: DecisionStatus,
196    /// The decision context.
197    pub context: DecisionContext,
198    /// Criteria used for evaluation.
199    pub criteria: Vec<Criterion>,
200    /// Options considered.
201    pub options: Vec<DecisionOption>,
202    /// The ruling.
203    pub ruling: Ruling,
204}
205
206/// Status of a decision.
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum DecisionStatus {
210    /// Proposed but not yet accepted.
211    Proposed,
212    /// Accepted and in effect.
213    Accepted,
214    /// Superseded by a later decision.
215    Superseded,
216    /// Was accepted but later reversed.
217    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    /// Render as Markdown (ADR format).
233    pub fn render_markdown(&self) -> String {
234        let mut md = String::with_capacity(4096);
235
236        // Header
237        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        // Context
246        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        // Decision criteria
273        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        // Options
287        if !self.options.is_empty() {
288            md.push_str("## Options Considered\n\n");
289
290            // Comparison table
291            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            // Detailed breakdowns
323            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        // Decision
352        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        // Related decisions
375        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    /// Write the ADR to a file.
387    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    /// Get the next ADR number by scanning existing ADR files in a directory.
403    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// ── Oracle session ─────────────────────────────────────────────────────
427
428/// An oracle session that tracks the decision-making lifecycle.
429#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct OracleSession {
431    /// Current phase.
432    pub phase: OraclePhase,
433    /// The question being decided.
434    pub question: String,
435    /// Optional project root.
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub project_root: Option<PathBuf>,
438    /// Loaded context.
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub context: Option<DecisionContext>,
441    /// Evaluation criteria.
442    pub criteria: Vec<Criterion>,
443    /// Options being considered.
444    pub options: Vec<DecisionOption>,
445    /// The ruling (set in Rule phase).
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub ruling: Option<Ruling>,
448    /// The final ADR (set in Document phase).
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub record: Option<DecisionRecord>,
451}
452
453impl OracleSession {
454    /// Create a new oracle session.
455    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    /// Set the project root.
469    pub fn with_project_root(mut self, root: impl Into<PathBuf>) -> Self {
470        self.project_root = Some(root.into());
471        self
472    }
473
474    /// Advance to the next phase.
475    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    /// Set phase directly.
489    pub fn set_phase(&mut self, phase: OraclePhase) {
490        self.phase = phase;
491    }
492
493    /// Set the decision context.
494    pub fn set_context(&mut self, ctx: DecisionContext) {
495        self.context = Some(ctx);
496    }
497
498    /// Add a criterion.
499    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    /// Add an option.
508    pub fn add_option(&mut self, option: DecisionOption) {
509        self.options.push(option);
510    }
511
512    /// Number of options.
513    pub fn option_count(&self) -> usize {
514        self.options.len()
515    }
516
517    /// Score all options against the criteria.
518    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    /// Set the ruling.
526    pub fn set_ruling(&mut self, ruling: Ruling) {
527        self.ruling = Some(ruling);
528    }
529
530    /// Finalize the decision record.
531    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        // Determine ADR number
538        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    /// Write the ADR to disk.
561    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
583// ── Skill prompt ───────────────────────────────────────────────────────
584
585/// The oracle skill struct.
586pub struct OracleSkill;
587
588impl OracleSkill {
589    /// Create a new oracle skill instance.
590    pub fn new() -> Self {
591        Self
592    }
593
594    /// Generate the system-prompt fragment for the oracle skill.
595    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
669// ── Helpers ────────────────────────────────────────────────────────────
670
671fn 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// ── Tests ──────────────────────────────────────────────────────────────
690
691#[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        // HashMap: (5*3 + 4*2) / (5*3 + 5*2) * 100 = 23/25 * 100 = 92
831        assert_eq!(session.options[0].total_score, Some(92.0));
832        // BTreeMap: (3*3 + 5*2) / 25 * 100 = 19/25 * 100 = 76
833        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        // Perfect score: (5*3 + 5*2) / (5*3 + 5*2) = 100
861        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        // Write to file
935        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        // Create existing ADR to test numbering
1078        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        // Phase 1: LoadContext
1086        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        // Phase 2: Frame
1096        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(); // Evaluate
1127
1128        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(); // Rule
1134
1135        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(); // Document
1144
1145        session.finalize(DecisionStatus::Accepted).unwrap();
1146
1147        // Should be ADR-0002 since ADR-0001 exists
1148        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}