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