Skip to main content

data_modelling_core/models/
decision.rs

1//! Decision model for MADR-compliant decision records
2//!
3//! Implements the Data Decision Log (DDL) feature for tracking architectural
4//! and data-related decisions using the MADR (Markdown Any Decision Records)
5//! template format.
6//!
7//! ## File Format
8//!
9//! Decision records are stored as `.madr.yaml` files following the naming convention:
10//! `{workspace}_{domain}_adr-{number}.madr.yaml`
11//!
12//! ## Example
13//!
14//! ```yaml
15//! id: 550e8400-e29b-41d4-a716-446655440000
16//! number: 1
17//! title: "Use ODCS v3.1.0 for all data contracts"
18//! status: accepted
19//! category: datadesign
20//! domain: sales
21//! date: 2026-01-07T10:00:00Z
22//! deciders:
23//!   - data-architecture@company.com
24//! context: |
25//!   We need a standard format for defining data contracts.
26//! decision: |
27//!   We will adopt ODCS v3.1.0 as the standard format.
28//! consequences: |
29//!   Positive: Consistent contracts across domains
30//! ```
31
32use chrono::{DateTime, Utc};
33use serde::{Deserialize, Serialize};
34use uuid::Uuid;
35
36use super::Tag;
37
38/// Decision status in lifecycle
39///
40/// Decisions follow a lifecycle: Draft → Proposed → Accepted → [Deprecated | Superseded | Rejected]
41#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "lowercase")]
43pub enum DecisionStatus {
44    /// Decision is in draft state, not yet proposed
45    Draft,
46    /// Decision has been proposed but not yet accepted
47    #[default]
48    Proposed,
49    /// Decision has been accepted and is in effect
50    Accepted,
51    /// Decision was rejected
52    Rejected,
53    /// Decision has been replaced by another decision
54    Superseded,
55    /// Decision is no longer valid but not replaced
56    Deprecated,
57}
58
59impl std::fmt::Display for DecisionStatus {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            DecisionStatus::Draft => write!(f, "Draft"),
63            DecisionStatus::Proposed => write!(f, "Proposed"),
64            DecisionStatus::Accepted => write!(f, "Accepted"),
65            DecisionStatus::Rejected => write!(f, "Rejected"),
66            DecisionStatus::Superseded => write!(f, "Superseded"),
67            DecisionStatus::Deprecated => write!(f, "Deprecated"),
68        }
69    }
70}
71
72/// Decision category
73///
74/// Categories help organize decisions by their domain of impact.
75#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "lowercase")]
77pub enum DecisionCategory {
78    /// System architecture decisions
79    #[default]
80    Architecture,
81    /// Technology choices
82    Technology,
83    /// Process-related decisions
84    Process,
85    /// Security-related decisions
86    Security,
87    /// Data-related decisions
88    Data,
89    /// Integration decisions
90    Integration,
91    /// Data design and modeling decisions
92    DataDesign,
93    /// Workflow and process decisions
94    Workflow,
95    /// Data model structure decisions
96    Model,
97    /// Data governance decisions
98    Governance,
99    /// Performance optimization decisions
100    Performance,
101    /// Compliance and regulatory decisions
102    Compliance,
103    /// Infrastructure decisions
104    Infrastructure,
105    /// Tooling choices
106    Tooling,
107}
108
109impl std::fmt::Display for DecisionCategory {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            DecisionCategory::Architecture => write!(f, "Architecture"),
113            DecisionCategory::Technology => write!(f, "Technology"),
114            DecisionCategory::Process => write!(f, "Process"),
115            DecisionCategory::Security => write!(f, "Security"),
116            DecisionCategory::Data => write!(f, "Data"),
117            DecisionCategory::Integration => write!(f, "Integration"),
118            DecisionCategory::DataDesign => write!(f, "Data Design"),
119            DecisionCategory::Workflow => write!(f, "Workflow"),
120            DecisionCategory::Model => write!(f, "Model"),
121            DecisionCategory::Governance => write!(f, "Governance"),
122            DecisionCategory::Performance => write!(f, "Performance"),
123            DecisionCategory::Compliance => write!(f, "Compliance"),
124            DecisionCategory::Infrastructure => write!(f, "Infrastructure"),
125            DecisionCategory::Tooling => write!(f, "Tooling"),
126        }
127    }
128}
129
130/// Priority level for decision drivers
131#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
132#[serde(rename_all = "lowercase")]
133pub enum DriverPriority {
134    High,
135    #[default]
136    Medium,
137    Low,
138}
139
140/// Driver/reason for the decision
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
142pub struct DecisionDriver {
143    /// Description of why this is a driver
144    pub description: String,
145    /// Priority of this driver
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub priority: Option<DriverPriority>,
148}
149
150impl DecisionDriver {
151    /// Create a new decision driver
152    pub fn new(description: impl Into<String>) -> Self {
153        Self {
154            description: description.into(),
155            priority: None,
156        }
157    }
158
159    /// Create a decision driver with priority
160    pub fn with_priority(description: impl Into<String>, priority: DriverPriority) -> Self {
161        Self {
162            description: description.into(),
163            priority: Some(priority),
164        }
165    }
166}
167
168/// Option considered during decision making
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
170pub struct DecisionOption {
171    /// Name of the option
172    pub name: String,
173    /// Description of the option
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub description: Option<String>,
176    /// Advantages of this option
177    #[serde(default, skip_serializing_if = "Vec::is_empty")]
178    pub pros: Vec<String>,
179    /// Disadvantages of this option
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub cons: Vec<String>,
182    /// Whether this option was selected
183    pub selected: bool,
184}
185
186impl DecisionOption {
187    /// Create a new decision option
188    pub fn new(name: impl Into<String>, selected: bool) -> Self {
189        Self {
190            name: name.into(),
191            description: None,
192            pros: Vec::new(),
193            cons: Vec::new(),
194            selected,
195        }
196    }
197
198    /// Create a decision option with full details
199    pub fn with_details(
200        name: impl Into<String>,
201        description: impl Into<String>,
202        pros: Vec<String>,
203        cons: Vec<String>,
204        selected: bool,
205    ) -> Self {
206        Self {
207            name: name.into(),
208            description: Some(description.into()),
209            pros,
210            cons,
211            selected,
212        }
213    }
214}
215
216/// Relationship type between a decision and an asset
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218#[serde(rename_all = "lowercase")]
219pub enum AssetRelationship {
220    /// Decision affects this asset
221    Affects,
222    /// Decision is implemented by this asset
223    Implements,
224    /// Decision deprecates this asset
225    Deprecates,
226}
227
228/// Link to an asset (table, relationship, product, etc.)
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
230#[serde(rename_all = "camelCase")]
231pub struct AssetLink {
232    /// Type of asset (odcs, odps, cads, relationship)
233    #[serde(alias = "asset_type")]
234    pub asset_type: String,
235    /// UUID of the linked asset
236    #[serde(alias = "asset_id")]
237    pub asset_id: Uuid,
238    /// Name of the linked asset
239    #[serde(alias = "asset_name")]
240    pub asset_name: String,
241    /// Relationship between decision and asset
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub relationship: Option<AssetRelationship>,
244}
245
246impl AssetLink {
247    /// Create a new asset link
248    pub fn new(
249        asset_type: impl Into<String>,
250        asset_id: Uuid,
251        asset_name: impl Into<String>,
252    ) -> Self {
253        Self {
254            asset_type: asset_type.into(),
255            asset_id,
256            asset_name: asset_name.into(),
257            relationship: None,
258        }
259    }
260
261    /// Create an asset link with relationship
262    pub fn with_relationship(
263        asset_type: impl Into<String>,
264        asset_id: Uuid,
265        asset_name: impl Into<String>,
266        relationship: AssetRelationship,
267    ) -> Self {
268        Self {
269            asset_type: asset_type.into(),
270            asset_id,
271            asset_name: asset_name.into(),
272            relationship: Some(relationship),
273        }
274    }
275}
276
277/// Compliance assessment for the decision
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
279#[serde(rename_all = "camelCase")]
280pub struct ComplianceAssessment {
281    /// Impact on regulatory requirements
282    #[serde(skip_serializing_if = "Option::is_none", alias = "regulatory_impact")]
283    pub regulatory_impact: Option<String>,
284    /// Privacy impact assessment
285    #[serde(skip_serializing_if = "Option::is_none", alias = "privacy_assessment")]
286    pub privacy_assessment: Option<String>,
287    /// Security impact assessment
288    #[serde(skip_serializing_if = "Option::is_none", alias = "security_assessment")]
289    pub security_assessment: Option<String>,
290    /// Applicable compliance frameworks (GDPR, SOC2, HIPAA, etc.)
291    #[serde(default, skip_serializing_if = "Vec::is_empty")]
292    pub frameworks: Vec<String>,
293}
294
295impl ComplianceAssessment {
296    /// Check if the assessment is empty
297    pub fn is_empty(&self) -> bool {
298        self.regulatory_impact.is_none()
299            && self.privacy_assessment.is_none()
300            && self.security_assessment.is_none()
301            && self.frameworks.is_empty()
302    }
303}
304
305/// Contact details for decision ownership
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
307pub struct DecisionContact {
308    /// Email address
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub email: Option<String>,
311    /// Contact name
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub name: Option<String>,
314    /// Role or team
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub role: Option<String>,
317}
318
319/// RACI matrix for decision responsibility assignment
320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
321pub struct RaciMatrix {
322    /// Responsible - Those who do the work to complete the task
323    #[serde(default, skip_serializing_if = "Vec::is_empty")]
324    pub responsible: Vec<String>,
325    /// Accountable - The one ultimately answerable for the decision
326    #[serde(default, skip_serializing_if = "Vec::is_empty")]
327    pub accountable: Vec<String>,
328    /// Consulted - Those whose opinions are sought
329    #[serde(default, skip_serializing_if = "Vec::is_empty")]
330    pub consulted: Vec<String>,
331    /// Informed - Those who are kept up-to-date on progress
332    #[serde(default, skip_serializing_if = "Vec::is_empty")]
333    pub informed: Vec<String>,
334}
335
336impl RaciMatrix {
337    /// Check if the RACI matrix is empty
338    pub fn is_empty(&self) -> bool {
339        self.responsible.is_empty()
340            && self.accountable.is_empty()
341            && self.consulted.is_empty()
342            && self.informed.is_empty()
343    }
344}
345
346/// MADR-compliant Decision Record
347///
348/// Represents an architectural or data decision following the MADR template.
349#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
350#[serde(rename_all = "camelCase")]
351pub struct Decision {
352    /// Unique identifier for the decision
353    pub id: Uuid,
354    /// Decision number - can be sequential (1, 2, 3) or timestamp-based (YYMMDDHHmm format)
355    /// Timestamp format prevents merge conflicts in distributed Git workflows
356    pub number: u64,
357    /// Short title describing the decision
358    pub title: String,
359    /// Current status of the decision
360    pub status: DecisionStatus,
361    /// Category of the decision
362    pub category: DecisionCategory,
363    /// Domain this decision belongs to (optional, string name)
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub domain: Option<String>,
366    /// Domain UUID reference (optional)
367    #[serde(skip_serializing_if = "Option::is_none", alias = "domain_id")]
368    pub domain_id: Option<Uuid>,
369    /// Workspace UUID reference (optional)
370    #[serde(skip_serializing_if = "Option::is_none", alias = "workspace_id")]
371    pub workspace_id: Option<Uuid>,
372
373    // MADR template fields
374    /// Date the decision was made
375    pub date: DateTime<Utc>,
376    /// When the decision was accepted/finalized
377    #[serde(skip_serializing_if = "Option::is_none", alias = "decided_at")]
378    pub decided_at: Option<DateTime<Utc>>,
379    /// Authors of the decision record
380    #[serde(default, skip_serializing_if = "Vec::is_empty")]
381    pub authors: Vec<String>,
382    /// People or teams who made the decision (deciders)
383    #[serde(default, skip_serializing_if = "Vec::is_empty")]
384    pub deciders: Vec<String>,
385    /// People or teams consulted during decision making (RACI - Consulted)
386    #[serde(default, skip_serializing_if = "Vec::is_empty")]
387    pub consulted: Vec<String>,
388    /// People or teams to be informed about the decision (RACI - Informed)
389    #[serde(default, skip_serializing_if = "Vec::is_empty")]
390    pub informed: Vec<String>,
391    /// Problem statement and context for the decision
392    pub context: String,
393    /// Reasons driving this decision
394    #[serde(default, skip_serializing_if = "Vec::is_empty")]
395    pub drivers: Vec<DecisionDriver>,
396    /// Options that were considered
397    #[serde(default, skip_serializing_if = "Vec::is_empty")]
398    pub options: Vec<DecisionOption>,
399    /// The decision that was made
400    pub decision: String,
401    /// Positive and negative consequences of the decision
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub consequences: Option<String>,
404
405    // Linking
406    /// Assets affected by this decision
407    #[serde(
408        default,
409        skip_serializing_if = "Vec::is_empty",
410        alias = "linked_assets"
411    )]
412    pub linked_assets: Vec<AssetLink>,
413    /// ID of the decision this supersedes
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub supersedes: Option<Uuid>,
416    /// ID of the decision that superseded this
417    #[serde(skip_serializing_if = "Option::is_none", alias = "superseded_by")]
418    pub superseded_by: Option<Uuid>,
419    /// IDs of related decisions
420    #[serde(
421        default,
422        skip_serializing_if = "Vec::is_empty",
423        alias = "related_decisions"
424    )]
425    pub related_decisions: Vec<Uuid>,
426    /// IDs of related knowledge articles
427    #[serde(
428        default,
429        skip_serializing_if = "Vec::is_empty",
430        alias = "related_knowledge"
431    )]
432    pub related_knowledge: Vec<Uuid>,
433    /// UUIDs of related sketches
434    #[serde(
435        default,
436        skip_serializing_if = "Vec::is_empty",
437        alias = "linked_sketches"
438    )]
439    pub linked_sketches: Vec<Uuid>,
440
441    // Compliance (from feature request)
442    /// Compliance assessment
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub compliance: Option<ComplianceAssessment>,
445
446    // Confirmation tracking (from feature request)
447    /// Date the decision was confirmed/reviewed
448    #[serde(skip_serializing_if = "Option::is_none", alias = "confirmation_date")]
449    pub confirmation_date: Option<DateTime<Utc>>,
450    /// Notes from confirmation review
451    #[serde(skip_serializing_if = "Option::is_none", alias = "confirmation_notes")]
452    pub confirmation_notes: Option<String>,
453
454    // Standard metadata
455    /// Tags for categorization
456    #[serde(default, skip_serializing_if = "Vec::is_empty")]
457    pub tags: Vec<Tag>,
458    /// Additional notes
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub notes: Option<String>,
461
462    /// Creation timestamp
463    #[serde(alias = "created_at")]
464    pub created_at: DateTime<Utc>,
465    /// Last modification timestamp
466    #[serde(alias = "updated_at")]
467    pub updated_at: DateTime<Utc>,
468}
469
470impl Decision {
471    /// Create a new decision with required fields
472    pub fn new(
473        number: u64,
474        title: impl Into<String>,
475        context: impl Into<String>,
476        decision: impl Into<String>,
477    ) -> Self {
478        let now = Utc::now();
479        Self {
480            id: Self::generate_id(number),
481            number,
482            title: title.into(),
483            status: DecisionStatus::Proposed,
484            category: DecisionCategory::Architecture,
485            domain: None,
486            domain_id: None,
487            workspace_id: None,
488            date: now,
489            decided_at: None,
490            authors: Vec::new(),
491            deciders: Vec::new(),
492            consulted: Vec::new(),
493            informed: Vec::new(),
494            context: context.into(),
495            drivers: Vec::new(),
496            options: Vec::new(),
497            decision: decision.into(),
498            consequences: None,
499            linked_assets: Vec::new(),
500            supersedes: None,
501            superseded_by: None,
502            related_decisions: Vec::new(),
503            related_knowledge: Vec::new(),
504            linked_sketches: Vec::new(),
505            compliance: None,
506            confirmation_date: None,
507            confirmation_notes: None,
508            tags: Vec::new(),
509            notes: None,
510            created_at: now,
511            updated_at: now,
512        }
513    }
514
515    /// Create a new decision with a timestamp-based number (YYMMDDHHmm format)
516    /// This format prevents merge conflicts in distributed Git workflows
517    pub fn new_with_timestamp(
518        title: impl Into<String>,
519        context: impl Into<String>,
520        decision: impl Into<String>,
521    ) -> Self {
522        let now = Utc::now();
523        let number = Self::generate_timestamp_number(&now);
524        Self::new(number, title, context, decision)
525    }
526
527    /// Generate a timestamp-based decision number in YYMMDDHHmm format
528    pub fn generate_timestamp_number(dt: &DateTime<Utc>) -> u64 {
529        let formatted = dt.format("%y%m%d%H%M").to_string();
530        formatted.parse().unwrap_or(0)
531    }
532
533    /// Generate a deterministic UUID for a decision based on its number
534    pub fn generate_id(number: u64) -> Uuid {
535        // Use UUID v5 with a namespace for decisions
536        let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); // URL namespace
537        let name = format!("decision:{}", number);
538        Uuid::new_v5(&namespace, name.as_bytes())
539    }
540
541    /// Add an author
542    pub fn add_author(mut self, author: impl Into<String>) -> Self {
543        self.authors.push(author.into());
544        self.updated_at = Utc::now();
545        self
546    }
547
548    /// Set consulted parties (RACI - Consulted)
549    pub fn add_consulted(mut self, consulted: impl Into<String>) -> Self {
550        self.consulted.push(consulted.into());
551        self.updated_at = Utc::now();
552        self
553    }
554
555    /// Set informed parties (RACI - Informed)
556    pub fn add_informed(mut self, informed: impl Into<String>) -> Self {
557        self.informed.push(informed.into());
558        self.updated_at = Utc::now();
559        self
560    }
561
562    /// Add a related decision
563    pub fn add_related_decision(mut self, decision_id: Uuid) -> Self {
564        self.related_decisions.push(decision_id);
565        self.updated_at = Utc::now();
566        self
567    }
568
569    /// Add a related knowledge article
570    pub fn add_related_knowledge(mut self, article_id: Uuid) -> Self {
571        self.related_knowledge.push(article_id);
572        self.updated_at = Utc::now();
573        self
574    }
575
576    /// Link to a sketch
577    pub fn link_sketch(mut self, sketch_id: Uuid) -> Self {
578        if !self.linked_sketches.contains(&sketch_id) {
579            self.linked_sketches.push(sketch_id);
580            self.updated_at = Utc::now();
581        }
582        self
583    }
584
585    /// Set decided_at timestamp
586    pub fn with_decided_at(mut self, decided_at: DateTime<Utc>) -> Self {
587        self.decided_at = Some(decided_at);
588        self.updated_at = Utc::now();
589        self
590    }
591
592    /// Set the domain ID
593    pub fn with_domain_id(mut self, domain_id: Uuid) -> Self {
594        self.domain_id = Some(domain_id);
595        self.updated_at = Utc::now();
596        self
597    }
598
599    /// Set the workspace ID
600    pub fn with_workspace_id(mut self, workspace_id: Uuid) -> Self {
601        self.workspace_id = Some(workspace_id);
602        self.updated_at = Utc::now();
603        self
604    }
605
606    /// Set the decision status
607    pub fn with_status(mut self, status: DecisionStatus) -> Self {
608        self.status = status;
609        self.updated_at = Utc::now();
610        self
611    }
612
613    /// Set the decision category
614    pub fn with_category(mut self, category: DecisionCategory) -> Self {
615        self.category = category;
616        self.updated_at = Utc::now();
617        self
618    }
619
620    /// Set the domain
621    pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
622        self.domain = Some(domain.into());
623        self.updated_at = Utc::now();
624        self
625    }
626
627    /// Add a decider
628    pub fn add_decider(mut self, decider: impl Into<String>) -> Self {
629        self.deciders.push(decider.into());
630        self.updated_at = Utc::now();
631        self
632    }
633
634    /// Add a driver
635    pub fn add_driver(mut self, driver: DecisionDriver) -> Self {
636        self.drivers.push(driver);
637        self.updated_at = Utc::now();
638        self
639    }
640
641    /// Add an option
642    pub fn add_option(mut self, option: DecisionOption) -> Self {
643        self.options.push(option);
644        self.updated_at = Utc::now();
645        self
646    }
647
648    /// Set consequences
649    pub fn with_consequences(mut self, consequences: impl Into<String>) -> Self {
650        self.consequences = Some(consequences.into());
651        self.updated_at = Utc::now();
652        self
653    }
654
655    /// Add an asset link
656    pub fn add_asset_link(mut self, link: AssetLink) -> Self {
657        self.linked_assets.push(link);
658        self.updated_at = Utc::now();
659        self
660    }
661
662    /// Set compliance assessment
663    pub fn with_compliance(mut self, compliance: ComplianceAssessment) -> Self {
664        self.compliance = Some(compliance);
665        self.updated_at = Utc::now();
666        self
667    }
668
669    /// Mark this decision as superseding another
670    pub fn supersedes_decision(mut self, other_id: Uuid) -> Self {
671        self.supersedes = Some(other_id);
672        self.updated_at = Utc::now();
673        self
674    }
675
676    /// Mark this decision as superseded by another
677    pub fn superseded_by_decision(&mut self, other_id: Uuid) {
678        self.superseded_by = Some(other_id);
679        self.status = DecisionStatus::Superseded;
680        self.updated_at = Utc::now();
681    }
682
683    /// Add a tag
684    pub fn add_tag(mut self, tag: Tag) -> Self {
685        self.tags.push(tag);
686        self.updated_at = Utc::now();
687        self
688    }
689
690    /// Check if the decision number is timestamp-based (YYMMDDHHmm format - 10 digits)
691    pub fn is_timestamp_number(&self) -> bool {
692        self.number >= 1000000000 && self.number <= 9999999999
693    }
694
695    /// Format the decision number for display
696    /// Returns "ADR-0001" for sequential or "ADR-2601101234" for timestamp-based
697    pub fn formatted_number(&self) -> String {
698        if self.is_timestamp_number() {
699            format!("ADR-{}", self.number)
700        } else {
701            format!("ADR-{:04}", self.number)
702        }
703    }
704
705    /// Generate the YAML filename for this decision
706    pub fn filename(&self, workspace_name: &str) -> String {
707        let number_str = if self.is_timestamp_number() {
708            format!("{}", self.number)
709        } else {
710            format!("{:04}", self.number)
711        };
712
713        match &self.domain {
714            Some(domain) => format!(
715                "{}_{}_adr-{}.madr.yaml",
716                sanitize_name(workspace_name),
717                sanitize_name(domain),
718                number_str
719            ),
720            None => format!(
721                "{}_adr-{}.madr.yaml",
722                sanitize_name(workspace_name),
723                number_str
724            ),
725        }
726    }
727
728    /// Generate the Markdown filename for this decision
729    pub fn markdown_filename(&self) -> String {
730        let slug = slugify(&self.title);
731        if self.is_timestamp_number() {
732            format!("ADR-{}-{}.md", self.number, slug)
733        } else {
734            format!("ADR-{:04}-{}.md", self.number, slug)
735        }
736    }
737
738    /// Import from YAML
739    pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
740        serde_yaml::from_str(yaml_content)
741    }
742
743    /// Export to YAML
744    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
745        serde_yaml::to_string(self)
746    }
747
748    /// Export to pretty YAML
749    pub fn to_yaml_pretty(&self) -> Result<String, serde_yaml::Error> {
750        // serde_yaml already produces pretty output
751        serde_yaml::to_string(self)
752    }
753}
754
755/// Decision index entry for the decisions.yaml file
756#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
757pub struct DecisionIndexEntry {
758    /// Decision number (can be sequential or timestamp-based)
759    pub number: u64,
760    /// Decision UUID
761    pub id: Uuid,
762    /// Decision title
763    pub title: String,
764    /// Decision status
765    pub status: DecisionStatus,
766    /// Decision category
767    pub category: DecisionCategory,
768    /// Domain (if applicable)
769    #[serde(skip_serializing_if = "Option::is_none")]
770    pub domain: Option<String>,
771    /// Filename of the decision YAML file
772    pub file: String,
773}
774
775impl From<&Decision> for DecisionIndexEntry {
776    fn from(decision: &Decision) -> Self {
777        Self {
778            number: decision.number,
779            id: decision.id,
780            title: decision.title.clone(),
781            status: decision.status.clone(),
782            category: decision.category.clone(),
783            domain: decision.domain.clone(),
784            file: String::new(), // Set by caller
785        }
786    }
787}
788
789/// Decision log index (decisions.yaml)
790#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
791pub struct DecisionIndex {
792    /// Schema version
793    #[serde(alias = "schema_version")]
794    pub schema_version: String,
795    /// Last update timestamp
796    #[serde(skip_serializing_if = "Option::is_none", alias = "last_updated")]
797    pub last_updated: Option<DateTime<Utc>>,
798    /// List of decisions
799    #[serde(default)]
800    pub decisions: Vec<DecisionIndexEntry>,
801    /// Next available decision number (for sequential numbering)
802    #[serde(alias = "next_number")]
803    pub next_number: u64,
804    /// Whether to use timestamp-based numbering (YYMMDDHHmm format)
805    #[serde(default, alias = "use_timestamp_numbering")]
806    pub use_timestamp_numbering: bool,
807}
808
809impl Default for DecisionIndex {
810    fn default() -> Self {
811        Self::new()
812    }
813}
814
815impl DecisionIndex {
816    /// Create a new empty decision index
817    pub fn new() -> Self {
818        Self {
819            schema_version: "1.0".to_string(),
820            last_updated: Some(Utc::now()),
821            decisions: Vec::new(),
822            next_number: 1,
823            use_timestamp_numbering: false,
824        }
825    }
826
827    /// Create a new decision index with timestamp-based numbering
828    pub fn new_with_timestamp_numbering() -> Self {
829        Self {
830            schema_version: "1.0".to_string(),
831            last_updated: Some(Utc::now()),
832            decisions: Vec::new(),
833            next_number: 1,
834            use_timestamp_numbering: true,
835        }
836    }
837
838    /// Add a decision to the index
839    pub fn add_decision(&mut self, decision: &Decision, filename: String) {
840        let mut entry = DecisionIndexEntry::from(decision);
841        entry.file = filename;
842
843        // Remove existing entry with same number if present
844        self.decisions.retain(|d| d.number != decision.number);
845        self.decisions.push(entry);
846
847        // Sort by number
848        self.decisions.sort_by_key(|d| d.number);
849
850        // Update next number only for sequential numbering
851        if !self.use_timestamp_numbering && decision.number >= self.next_number {
852            self.next_number = decision.number + 1;
853        }
854
855        self.last_updated = Some(Utc::now());
856    }
857
858    /// Get the next available decision number
859    /// For timestamp-based numbering, generates a new timestamp
860    /// For sequential numbering, returns the next sequential number
861    pub fn get_next_number(&self) -> u64 {
862        if self.use_timestamp_numbering {
863            Decision::generate_timestamp_number(&Utc::now())
864        } else {
865            self.next_number
866        }
867    }
868
869    /// Find a decision by number
870    pub fn find_by_number(&self, number: u64) -> Option<&DecisionIndexEntry> {
871        self.decisions.iter().find(|d| d.number == number)
872    }
873
874    /// Import from YAML
875    pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
876        serde_yaml::from_str(yaml_content)
877    }
878
879    /// Export to YAML
880    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
881        serde_yaml::to_string(self)
882    }
883}
884
885/// Sanitize a name for use in filenames
886fn sanitize_name(name: &str) -> String {
887    name.chars()
888        .map(|c| match c {
889            ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
890            _ => c,
891        })
892        .collect::<String>()
893        .to_lowercase()
894}
895
896/// Create a URL-friendly slug from a title
897fn slugify(title: &str) -> String {
898    title
899        .to_lowercase()
900        .chars()
901        .map(|c| if c.is_alphanumeric() { c } else { '-' })
902        .collect::<String>()
903        .split('-')
904        .filter(|s| !s.is_empty())
905        .collect::<Vec<_>>()
906        .join("-")
907        .chars()
908        .take(50) // Limit slug length
909        .collect()
910}
911
912#[cfg(test)]
913mod tests {
914    use super::*;
915
916    #[test]
917    fn test_decision_new() {
918        let decision = Decision::new(
919            1,
920            "Use ODCS v3.1.0",
921            "We need a standard format",
922            "We will use ODCS v3.1.0",
923        );
924
925        assert_eq!(decision.number, 1);
926        assert_eq!(decision.title, "Use ODCS v3.1.0");
927        assert_eq!(decision.status, DecisionStatus::Proposed);
928        assert_eq!(decision.category, DecisionCategory::Architecture);
929    }
930
931    #[test]
932    fn test_decision_builder_pattern() {
933        let decision = Decision::new(1, "Test", "Context", "Decision")
934            .with_status(DecisionStatus::Accepted)
935            .with_category(DecisionCategory::DataDesign)
936            .with_domain("sales")
937            .add_decider("team@example.com")
938            .add_driver(DecisionDriver::with_priority(
939                "Need consistency",
940                DriverPriority::High,
941            ))
942            .with_consequences("Better consistency");
943
944        assert_eq!(decision.status, DecisionStatus::Accepted);
945        assert_eq!(decision.category, DecisionCategory::DataDesign);
946        assert_eq!(decision.domain, Some("sales".to_string()));
947        assert_eq!(decision.deciders.len(), 1);
948        assert_eq!(decision.drivers.len(), 1);
949        assert!(decision.consequences.is_some());
950    }
951
952    #[test]
953    fn test_decision_id_generation() {
954        let id1 = Decision::generate_id(1);
955        let id2 = Decision::generate_id(1);
956        let id3 = Decision::generate_id(2);
957
958        // Same number should generate same ID
959        assert_eq!(id1, id2);
960        // Different numbers should generate different IDs
961        assert_ne!(id1, id3);
962    }
963
964    #[test]
965    fn test_decision_filename() {
966        let decision = Decision::new(1, "Test", "Context", "Decision");
967        assert_eq!(
968            decision.filename("enterprise"),
969            "enterprise_adr-0001.madr.yaml"
970        );
971
972        let decision_with_domain = decision.with_domain("sales");
973        assert_eq!(
974            decision_with_domain.filename("enterprise"),
975            "enterprise_sales_adr-0001.madr.yaml"
976        );
977    }
978
979    #[test]
980    fn test_decision_markdown_filename() {
981        let decision = Decision::new(
982            1,
983            "Use ODCS v3.1.0 for all data contracts",
984            "Context",
985            "Decision",
986        );
987        let filename = decision.markdown_filename();
988        assert!(filename.starts_with("ADR-0001-"));
989        assert!(filename.ends_with(".md"));
990    }
991
992    #[test]
993    fn test_decision_yaml_roundtrip() {
994        let decision = Decision::new(1, "Test Decision", "Some context", "The decision")
995            .with_status(DecisionStatus::Accepted)
996            .with_domain("test");
997
998        let yaml = decision.to_yaml().unwrap();
999        let parsed = Decision::from_yaml(&yaml).unwrap();
1000
1001        assert_eq!(decision.id, parsed.id);
1002        assert_eq!(decision.title, parsed.title);
1003        assert_eq!(decision.status, parsed.status);
1004        assert_eq!(decision.domain, parsed.domain);
1005    }
1006
1007    #[test]
1008    fn test_decision_index() {
1009        let mut index = DecisionIndex::new();
1010        assert_eq!(index.get_next_number(), 1);
1011
1012        let decision1 = Decision::new(1, "First", "Context", "Decision");
1013        index.add_decision(&decision1, "test_adr-0001.madr.yaml".to_string());
1014
1015        assert_eq!(index.decisions.len(), 1);
1016        assert_eq!(index.get_next_number(), 2);
1017
1018        let decision2 = Decision::new(2, "Second", "Context", "Decision");
1019        index.add_decision(&decision2, "test_adr-0002.madr.yaml".to_string());
1020
1021        assert_eq!(index.decisions.len(), 2);
1022        assert_eq!(index.get_next_number(), 3);
1023    }
1024
1025    #[test]
1026    fn test_slugify() {
1027        assert_eq!(slugify("Use ODCS v3.1.0"), "use-odcs-v3-1-0");
1028        assert_eq!(slugify("Hello World"), "hello-world");
1029        assert_eq!(slugify("test--double"), "test-double");
1030    }
1031
1032    #[test]
1033    fn test_decision_status_display() {
1034        assert_eq!(format!("{}", DecisionStatus::Proposed), "Proposed");
1035        assert_eq!(format!("{}", DecisionStatus::Accepted), "Accepted");
1036        assert_eq!(format!("{}", DecisionStatus::Deprecated), "Deprecated");
1037        assert_eq!(format!("{}", DecisionStatus::Superseded), "Superseded");
1038        assert_eq!(format!("{}", DecisionStatus::Rejected), "Rejected");
1039    }
1040
1041    #[test]
1042    fn test_asset_link() {
1043        let link = AssetLink::with_relationship(
1044            "odcs",
1045            Uuid::new_v4(),
1046            "orders",
1047            AssetRelationship::Implements,
1048        );
1049
1050        assert_eq!(link.asset_type, "odcs");
1051        assert_eq!(link.asset_name, "orders");
1052        assert_eq!(link.relationship, Some(AssetRelationship::Implements));
1053    }
1054
1055    #[test]
1056    fn test_timestamp_number_generation() {
1057        use chrono::TimeZone;
1058        let dt = Utc.with_ymd_and_hms(2026, 1, 10, 14, 30, 0).unwrap();
1059        let number = Decision::generate_timestamp_number(&dt);
1060        assert_eq!(number, 2601101430);
1061    }
1062
1063    #[test]
1064    fn test_is_timestamp_number() {
1065        let sequential_decision = Decision::new(1, "Test", "Context", "Decision");
1066        assert!(!sequential_decision.is_timestamp_number());
1067
1068        let timestamp_decision = Decision::new(2601101430, "Test", "Context", "Decision");
1069        assert!(timestamp_decision.is_timestamp_number());
1070    }
1071
1072    #[test]
1073    fn test_timestamp_decision_filename() {
1074        let decision = Decision::new(2601101430, "Test", "Context", "Decision");
1075        assert_eq!(
1076            decision.filename("enterprise"),
1077            "enterprise_adr-2601101430.madr.yaml"
1078        );
1079    }
1080
1081    #[test]
1082    fn test_timestamp_decision_markdown_filename() {
1083        let decision = Decision::new(2601101430, "Test Decision", "Context", "Decision");
1084        let filename = decision.markdown_filename();
1085        assert!(filename.starts_with("ADR-2601101430-"));
1086        assert!(filename.ends_with(".md"));
1087    }
1088
1089    #[test]
1090    fn test_decision_with_consulted_informed() {
1091        let decision = Decision::new(1, "Test", "Context", "Decision")
1092            .add_consulted("security@example.com")
1093            .add_informed("stakeholders@example.com");
1094
1095        assert_eq!(decision.consulted.len(), 1);
1096        assert_eq!(decision.informed.len(), 1);
1097        assert_eq!(decision.consulted[0], "security@example.com");
1098        assert_eq!(decision.informed[0], "stakeholders@example.com");
1099    }
1100
1101    #[test]
1102    fn test_decision_with_authors() {
1103        let decision = Decision::new(1, "Test", "Context", "Decision")
1104            .add_author("author1@example.com")
1105            .add_author("author2@example.com");
1106
1107        assert_eq!(decision.authors.len(), 2);
1108    }
1109
1110    #[test]
1111    fn test_decision_index_with_timestamp_numbering() {
1112        let index = DecisionIndex::new_with_timestamp_numbering();
1113        assert!(index.use_timestamp_numbering);
1114
1115        // The next number should be a timestamp
1116        let next = index.get_next_number();
1117        assert!(next >= 1000000000); // Timestamp format check
1118    }
1119
1120    #[test]
1121    fn test_new_categories() {
1122        assert_eq!(format!("{}", DecisionCategory::Data), "Data");
1123        assert_eq!(format!("{}", DecisionCategory::Integration), "Integration");
1124    }
1125
1126    #[test]
1127    fn test_decision_with_related() {
1128        let related_decision_id = Uuid::new_v4();
1129        let related_knowledge_id = Uuid::new_v4();
1130
1131        let decision = Decision::new(1, "Test", "Context", "Decision")
1132            .add_related_decision(related_decision_id)
1133            .add_related_knowledge(related_knowledge_id);
1134
1135        assert_eq!(decision.related_decisions.len(), 1);
1136        assert_eq!(decision.related_knowledge.len(), 1);
1137        assert_eq!(decision.related_decisions[0], related_decision_id);
1138        assert_eq!(decision.related_knowledge[0], related_knowledge_id);
1139    }
1140
1141    #[test]
1142    fn test_decision_status_draft() {
1143        let decision =
1144            Decision::new(1, "Test", "Context", "Decision").with_status(DecisionStatus::Draft);
1145        assert_eq!(decision.status, DecisionStatus::Draft);
1146        assert_eq!(format!("{}", DecisionStatus::Draft), "Draft");
1147    }
1148}