data_modelling_sdk/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    pub asset_type: String,
234    /// UUID of the linked asset
235    pub asset_id: Uuid,
236    /// Name of the linked asset
237    pub asset_name: String,
238    /// Relationship between decision and asset
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub relationship: Option<AssetRelationship>,
241}
242
243impl AssetLink {
244    /// Create a new asset link
245    pub fn new(
246        asset_type: impl Into<String>,
247        asset_id: Uuid,
248        asset_name: impl Into<String>,
249    ) -> Self {
250        Self {
251            asset_type: asset_type.into(),
252            asset_id,
253            asset_name: asset_name.into(),
254            relationship: None,
255        }
256    }
257
258    /// Create an asset link with relationship
259    pub fn with_relationship(
260        asset_type: impl Into<String>,
261        asset_id: Uuid,
262        asset_name: impl Into<String>,
263        relationship: AssetRelationship,
264    ) -> Self {
265        Self {
266            asset_type: asset_type.into(),
267            asset_id,
268            asset_name: asset_name.into(),
269            relationship: Some(relationship),
270        }
271    }
272}
273
274/// Compliance assessment for the decision
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
276#[serde(rename_all = "camelCase")]
277pub struct ComplianceAssessment {
278    /// Impact on regulatory requirements
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub regulatory_impact: Option<String>,
281    /// Privacy impact assessment
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub privacy_assessment: Option<String>,
284    /// Security impact assessment
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub security_assessment: Option<String>,
287    /// Applicable compliance frameworks (GDPR, SOC2, HIPAA, etc.)
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub frameworks: Vec<String>,
290}
291
292impl ComplianceAssessment {
293    /// Check if the assessment is empty
294    pub fn is_empty(&self) -> bool {
295        self.regulatory_impact.is_none()
296            && self.privacy_assessment.is_none()
297            && self.security_assessment.is_none()
298            && self.frameworks.is_empty()
299    }
300}
301
302/// Contact details for decision ownership
303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
304pub struct DecisionContact {
305    /// Email address
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub email: Option<String>,
308    /// Contact name
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub name: Option<String>,
311    /// Role or team
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub role: Option<String>,
314}
315
316/// RACI matrix for decision responsibility assignment
317#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
318pub struct RaciMatrix {
319    /// Responsible - Those who do the work to complete the task
320    #[serde(default, skip_serializing_if = "Vec::is_empty")]
321    pub responsible: Vec<String>,
322    /// Accountable - The one ultimately answerable for the decision
323    #[serde(default, skip_serializing_if = "Vec::is_empty")]
324    pub accountable: Vec<String>,
325    /// Consulted - Those whose opinions are sought
326    #[serde(default, skip_serializing_if = "Vec::is_empty")]
327    pub consulted: Vec<String>,
328    /// Informed - Those who are kept up-to-date on progress
329    #[serde(default, skip_serializing_if = "Vec::is_empty")]
330    pub informed: Vec<String>,
331}
332
333impl RaciMatrix {
334    /// Check if the RACI matrix is empty
335    pub fn is_empty(&self) -> bool {
336        self.responsible.is_empty()
337            && self.accountable.is_empty()
338            && self.consulted.is_empty()
339            && self.informed.is_empty()
340    }
341}
342
343/// MADR-compliant Decision Record
344///
345/// Represents an architectural or data decision following the MADR template.
346#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
347#[serde(rename_all = "camelCase")]
348pub struct Decision {
349    /// Unique identifier for the decision
350    pub id: Uuid,
351    /// Decision number - can be sequential (1, 2, 3) or timestamp-based (YYMMDDHHmm format)
352    /// Timestamp format prevents merge conflicts in distributed Git workflows
353    pub number: u64,
354    /// Short title describing the decision
355    pub title: String,
356    /// Current status of the decision
357    pub status: DecisionStatus,
358    /// Category of the decision
359    pub category: DecisionCategory,
360    /// Domain this decision belongs to (optional, string name)
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub domain: Option<String>,
363    /// Domain UUID reference (optional)
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub domain_id: Option<Uuid>,
366    /// Workspace UUID reference (optional)
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub workspace_id: Option<Uuid>,
369
370    // MADR template fields
371    /// Date the decision was made
372    pub date: DateTime<Utc>,
373    /// When the decision was accepted/finalized
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub decided_at: Option<DateTime<Utc>>,
376    /// Authors of the decision record
377    #[serde(default, skip_serializing_if = "Vec::is_empty")]
378    pub authors: Vec<String>,
379    /// People or teams who made the decision (deciders)
380    #[serde(default, skip_serializing_if = "Vec::is_empty")]
381    pub deciders: Vec<String>,
382    /// People or teams consulted during decision making (RACI - Consulted)
383    #[serde(default, skip_serializing_if = "Vec::is_empty")]
384    pub consulted: Vec<String>,
385    /// People or teams to be informed about the decision (RACI - Informed)
386    #[serde(default, skip_serializing_if = "Vec::is_empty")]
387    pub informed: Vec<String>,
388    /// Problem statement and context for the decision
389    pub context: String,
390    /// Reasons driving this decision
391    #[serde(default, skip_serializing_if = "Vec::is_empty")]
392    pub drivers: Vec<DecisionDriver>,
393    /// Options that were considered
394    #[serde(default, skip_serializing_if = "Vec::is_empty")]
395    pub options: Vec<DecisionOption>,
396    /// The decision that was made
397    pub decision: String,
398    /// Positive and negative consequences of the decision
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub consequences: Option<String>,
401
402    // Linking
403    /// Assets affected by this decision
404    #[serde(default, skip_serializing_if = "Vec::is_empty")]
405    pub linked_assets: Vec<AssetLink>,
406    /// ID of the decision this supersedes
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub supersedes: Option<Uuid>,
409    /// ID of the decision that superseded this
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub superseded_by: Option<Uuid>,
412    /// IDs of related decisions
413    #[serde(default, skip_serializing_if = "Vec::is_empty")]
414    pub related_decisions: Vec<Uuid>,
415    /// IDs of related knowledge articles
416    #[serde(default, skip_serializing_if = "Vec::is_empty")]
417    pub related_knowledge: Vec<Uuid>,
418
419    // Compliance (from feature request)
420    /// Compliance assessment
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub compliance: Option<ComplianceAssessment>,
423
424    // Confirmation tracking (from feature request)
425    /// Date the decision was confirmed/reviewed
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub confirmation_date: Option<DateTime<Utc>>,
428    /// Notes from confirmation review
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub confirmation_notes: Option<String>,
431
432    // Standard metadata
433    /// Tags for categorization
434    #[serde(default, skip_serializing_if = "Vec::is_empty")]
435    pub tags: Vec<Tag>,
436    /// Additional notes
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub notes: Option<String>,
439
440    /// Creation timestamp
441    pub created_at: DateTime<Utc>,
442    /// Last modification timestamp
443    pub updated_at: DateTime<Utc>,
444}
445
446impl Decision {
447    /// Create a new decision with required fields
448    pub fn new(
449        number: u64,
450        title: impl Into<String>,
451        context: impl Into<String>,
452        decision: impl Into<String>,
453    ) -> Self {
454        let now = Utc::now();
455        Self {
456            id: Self::generate_id(number),
457            number,
458            title: title.into(),
459            status: DecisionStatus::Proposed,
460            category: DecisionCategory::Architecture,
461            domain: None,
462            domain_id: None,
463            workspace_id: None,
464            date: now,
465            decided_at: None,
466            authors: Vec::new(),
467            deciders: Vec::new(),
468            consulted: Vec::new(),
469            informed: Vec::new(),
470            context: context.into(),
471            drivers: Vec::new(),
472            options: Vec::new(),
473            decision: decision.into(),
474            consequences: None,
475            linked_assets: Vec::new(),
476            supersedes: None,
477            superseded_by: None,
478            related_decisions: Vec::new(),
479            related_knowledge: Vec::new(),
480            compliance: None,
481            confirmation_date: None,
482            confirmation_notes: None,
483            tags: Vec::new(),
484            notes: None,
485            created_at: now,
486            updated_at: now,
487        }
488    }
489
490    /// Create a new decision with a timestamp-based number (YYMMDDHHmm format)
491    /// This format prevents merge conflicts in distributed Git workflows
492    pub fn new_with_timestamp(
493        title: impl Into<String>,
494        context: impl Into<String>,
495        decision: impl Into<String>,
496    ) -> Self {
497        let now = Utc::now();
498        let number = Self::generate_timestamp_number(&now);
499        Self::new(number, title, context, decision)
500    }
501
502    /// Generate a timestamp-based decision number in YYMMDDHHmm format
503    pub fn generate_timestamp_number(dt: &DateTime<Utc>) -> u64 {
504        let formatted = dt.format("%y%m%d%H%M").to_string();
505        formatted.parse().unwrap_or(0)
506    }
507
508    /// Generate a deterministic UUID for a decision based on its number
509    pub fn generate_id(number: u64) -> Uuid {
510        // Use UUID v5 with a namespace for decisions
511        let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); // URL namespace
512        let name = format!("decision:{}", number);
513        Uuid::new_v5(&namespace, name.as_bytes())
514    }
515
516    /// Add an author
517    pub fn add_author(mut self, author: impl Into<String>) -> Self {
518        self.authors.push(author.into());
519        self.updated_at = Utc::now();
520        self
521    }
522
523    /// Set consulted parties (RACI - Consulted)
524    pub fn add_consulted(mut self, consulted: impl Into<String>) -> Self {
525        self.consulted.push(consulted.into());
526        self.updated_at = Utc::now();
527        self
528    }
529
530    /// Set informed parties (RACI - Informed)
531    pub fn add_informed(mut self, informed: impl Into<String>) -> Self {
532        self.informed.push(informed.into());
533        self.updated_at = Utc::now();
534        self
535    }
536
537    /// Add a related decision
538    pub fn add_related_decision(mut self, decision_id: Uuid) -> Self {
539        self.related_decisions.push(decision_id);
540        self.updated_at = Utc::now();
541        self
542    }
543
544    /// Add a related knowledge article
545    pub fn add_related_knowledge(mut self, article_id: Uuid) -> Self {
546        self.related_knowledge.push(article_id);
547        self.updated_at = Utc::now();
548        self
549    }
550
551    /// Set decided_at timestamp
552    pub fn with_decided_at(mut self, decided_at: DateTime<Utc>) -> Self {
553        self.decided_at = Some(decided_at);
554        self.updated_at = Utc::now();
555        self
556    }
557
558    /// Set the domain ID
559    pub fn with_domain_id(mut self, domain_id: Uuid) -> Self {
560        self.domain_id = Some(domain_id);
561        self.updated_at = Utc::now();
562        self
563    }
564
565    /// Set the workspace ID
566    pub fn with_workspace_id(mut self, workspace_id: Uuid) -> Self {
567        self.workspace_id = Some(workspace_id);
568        self.updated_at = Utc::now();
569        self
570    }
571
572    /// Set the decision status
573    pub fn with_status(mut self, status: DecisionStatus) -> Self {
574        self.status = status;
575        self.updated_at = Utc::now();
576        self
577    }
578
579    /// Set the decision category
580    pub fn with_category(mut self, category: DecisionCategory) -> Self {
581        self.category = category;
582        self.updated_at = Utc::now();
583        self
584    }
585
586    /// Set the domain
587    pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
588        self.domain = Some(domain.into());
589        self.updated_at = Utc::now();
590        self
591    }
592
593    /// Add a decider
594    pub fn add_decider(mut self, decider: impl Into<String>) -> Self {
595        self.deciders.push(decider.into());
596        self.updated_at = Utc::now();
597        self
598    }
599
600    /// Add a driver
601    pub fn add_driver(mut self, driver: DecisionDriver) -> Self {
602        self.drivers.push(driver);
603        self.updated_at = Utc::now();
604        self
605    }
606
607    /// Add an option
608    pub fn add_option(mut self, option: DecisionOption) -> Self {
609        self.options.push(option);
610        self.updated_at = Utc::now();
611        self
612    }
613
614    /// Set consequences
615    pub fn with_consequences(mut self, consequences: impl Into<String>) -> Self {
616        self.consequences = Some(consequences.into());
617        self.updated_at = Utc::now();
618        self
619    }
620
621    /// Add an asset link
622    pub fn add_asset_link(mut self, link: AssetLink) -> Self {
623        self.linked_assets.push(link);
624        self.updated_at = Utc::now();
625        self
626    }
627
628    /// Set compliance assessment
629    pub fn with_compliance(mut self, compliance: ComplianceAssessment) -> Self {
630        self.compliance = Some(compliance);
631        self.updated_at = Utc::now();
632        self
633    }
634
635    /// Mark this decision as superseding another
636    pub fn supersedes_decision(mut self, other_id: Uuid) -> Self {
637        self.supersedes = Some(other_id);
638        self.updated_at = Utc::now();
639        self
640    }
641
642    /// Mark this decision as superseded by another
643    pub fn superseded_by_decision(&mut self, other_id: Uuid) {
644        self.superseded_by = Some(other_id);
645        self.status = DecisionStatus::Superseded;
646        self.updated_at = Utc::now();
647    }
648
649    /// Add a tag
650    pub fn add_tag(mut self, tag: Tag) -> Self {
651        self.tags.push(tag);
652        self.updated_at = Utc::now();
653        self
654    }
655
656    /// Check if the decision number is timestamp-based (YYMMDDHHmm format - 10 digits)
657    pub fn is_timestamp_number(&self) -> bool {
658        self.number >= 1000000000 && self.number <= 9999999999
659    }
660
661    /// Format the decision number for display
662    /// Returns "ADR-0001" for sequential or "ADR-2601101234" for timestamp-based
663    pub fn formatted_number(&self) -> String {
664        if self.is_timestamp_number() {
665            format!("ADR-{}", self.number)
666        } else {
667            format!("ADR-{:04}", self.number)
668        }
669    }
670
671    /// Generate the YAML filename for this decision
672    pub fn filename(&self, workspace_name: &str) -> String {
673        let number_str = if self.is_timestamp_number() {
674            format!("{}", self.number)
675        } else {
676            format!("{:04}", self.number)
677        };
678
679        match &self.domain {
680            Some(domain) => format!(
681                "{}_{}_adr-{}.madr.yaml",
682                sanitize_name(workspace_name),
683                sanitize_name(domain),
684                number_str
685            ),
686            None => format!(
687                "{}_adr-{}.madr.yaml",
688                sanitize_name(workspace_name),
689                number_str
690            ),
691        }
692    }
693
694    /// Generate the Markdown filename for this decision
695    pub fn markdown_filename(&self) -> String {
696        let slug = slugify(&self.title);
697        if self.is_timestamp_number() {
698            format!("ADR-{}-{}.md", self.number, slug)
699        } else {
700            format!("ADR-{:04}-{}.md", self.number, slug)
701        }
702    }
703
704    /// Import from YAML
705    pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
706        serde_yaml::from_str(yaml_content)
707    }
708
709    /// Export to YAML
710    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
711        serde_yaml::to_string(self)
712    }
713
714    /// Export to pretty YAML
715    pub fn to_yaml_pretty(&self) -> Result<String, serde_yaml::Error> {
716        // serde_yaml already produces pretty output
717        serde_yaml::to_string(self)
718    }
719}
720
721/// Decision index entry for the decisions.yaml file
722#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
723pub struct DecisionIndexEntry {
724    /// Decision number (can be sequential or timestamp-based)
725    pub number: u64,
726    /// Decision UUID
727    pub id: Uuid,
728    /// Decision title
729    pub title: String,
730    /// Decision status
731    pub status: DecisionStatus,
732    /// Decision category
733    pub category: DecisionCategory,
734    /// Domain (if applicable)
735    #[serde(skip_serializing_if = "Option::is_none")]
736    pub domain: Option<String>,
737    /// Filename of the decision YAML file
738    pub file: String,
739}
740
741impl From<&Decision> for DecisionIndexEntry {
742    fn from(decision: &Decision) -> Self {
743        Self {
744            number: decision.number,
745            id: decision.id,
746            title: decision.title.clone(),
747            status: decision.status.clone(),
748            category: decision.category.clone(),
749            domain: decision.domain.clone(),
750            file: String::new(), // Set by caller
751        }
752    }
753}
754
755/// Decision log index (decisions.yaml)
756#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
757pub struct DecisionIndex {
758    /// Schema version
759    #[serde(alias = "schema_version")]
760    pub schema_version: String,
761    /// Last update timestamp
762    #[serde(skip_serializing_if = "Option::is_none", alias = "last_updated")]
763    pub last_updated: Option<DateTime<Utc>>,
764    /// List of decisions
765    #[serde(default)]
766    pub decisions: Vec<DecisionIndexEntry>,
767    /// Next available decision number (for sequential numbering)
768    #[serde(alias = "next_number")]
769    pub next_number: u64,
770    /// Whether to use timestamp-based numbering (YYMMDDHHmm format)
771    #[serde(default, alias = "use_timestamp_numbering")]
772    pub use_timestamp_numbering: bool,
773}
774
775impl Default for DecisionIndex {
776    fn default() -> Self {
777        Self::new()
778    }
779}
780
781impl DecisionIndex {
782    /// Create a new empty decision index
783    pub fn new() -> Self {
784        Self {
785            schema_version: "1.0".to_string(),
786            last_updated: Some(Utc::now()),
787            decisions: Vec::new(),
788            next_number: 1,
789            use_timestamp_numbering: false,
790        }
791    }
792
793    /// Create a new decision index with timestamp-based numbering
794    pub fn new_with_timestamp_numbering() -> Self {
795        Self {
796            schema_version: "1.0".to_string(),
797            last_updated: Some(Utc::now()),
798            decisions: Vec::new(),
799            next_number: 1,
800            use_timestamp_numbering: true,
801        }
802    }
803
804    /// Add a decision to the index
805    pub fn add_decision(&mut self, decision: &Decision, filename: String) {
806        let mut entry = DecisionIndexEntry::from(decision);
807        entry.file = filename;
808
809        // Remove existing entry with same number if present
810        self.decisions.retain(|d| d.number != decision.number);
811        self.decisions.push(entry);
812
813        // Sort by number
814        self.decisions.sort_by_key(|d| d.number);
815
816        // Update next number only for sequential numbering
817        if !self.use_timestamp_numbering && decision.number >= self.next_number {
818            self.next_number = decision.number + 1;
819        }
820
821        self.last_updated = Some(Utc::now());
822    }
823
824    /// Get the next available decision number
825    /// For timestamp-based numbering, generates a new timestamp
826    /// For sequential numbering, returns the next sequential number
827    pub fn get_next_number(&self) -> u64 {
828        if self.use_timestamp_numbering {
829            Decision::generate_timestamp_number(&Utc::now())
830        } else {
831            self.next_number
832        }
833    }
834
835    /// Find a decision by number
836    pub fn find_by_number(&self, number: u64) -> Option<&DecisionIndexEntry> {
837        self.decisions.iter().find(|d| d.number == number)
838    }
839
840    /// Import from YAML
841    pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
842        serde_yaml::from_str(yaml_content)
843    }
844
845    /// Export to YAML
846    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
847        serde_yaml::to_string(self)
848    }
849}
850
851/// Sanitize a name for use in filenames
852fn sanitize_name(name: &str) -> String {
853    name.chars()
854        .map(|c| match c {
855            ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
856            _ => c,
857        })
858        .collect::<String>()
859        .to_lowercase()
860}
861
862/// Create a URL-friendly slug from a title
863fn slugify(title: &str) -> String {
864    title
865        .to_lowercase()
866        .chars()
867        .map(|c| if c.is_alphanumeric() { c } else { '-' })
868        .collect::<String>()
869        .split('-')
870        .filter(|s| !s.is_empty())
871        .collect::<Vec<_>>()
872        .join("-")
873        .chars()
874        .take(50) // Limit slug length
875        .collect()
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881
882    #[test]
883    fn test_decision_new() {
884        let decision = Decision::new(
885            1,
886            "Use ODCS v3.1.0",
887            "We need a standard format",
888            "We will use ODCS v3.1.0",
889        );
890
891        assert_eq!(decision.number, 1);
892        assert_eq!(decision.title, "Use ODCS v3.1.0");
893        assert_eq!(decision.status, DecisionStatus::Proposed);
894        assert_eq!(decision.category, DecisionCategory::Architecture);
895    }
896
897    #[test]
898    fn test_decision_builder_pattern() {
899        let decision = Decision::new(1, "Test", "Context", "Decision")
900            .with_status(DecisionStatus::Accepted)
901            .with_category(DecisionCategory::DataDesign)
902            .with_domain("sales")
903            .add_decider("team@example.com")
904            .add_driver(DecisionDriver::with_priority(
905                "Need consistency",
906                DriverPriority::High,
907            ))
908            .with_consequences("Better consistency");
909
910        assert_eq!(decision.status, DecisionStatus::Accepted);
911        assert_eq!(decision.category, DecisionCategory::DataDesign);
912        assert_eq!(decision.domain, Some("sales".to_string()));
913        assert_eq!(decision.deciders.len(), 1);
914        assert_eq!(decision.drivers.len(), 1);
915        assert!(decision.consequences.is_some());
916    }
917
918    #[test]
919    fn test_decision_id_generation() {
920        let id1 = Decision::generate_id(1);
921        let id2 = Decision::generate_id(1);
922        let id3 = Decision::generate_id(2);
923
924        // Same number should generate same ID
925        assert_eq!(id1, id2);
926        // Different numbers should generate different IDs
927        assert_ne!(id1, id3);
928    }
929
930    #[test]
931    fn test_decision_filename() {
932        let decision = Decision::new(1, "Test", "Context", "Decision");
933        assert_eq!(
934            decision.filename("enterprise"),
935            "enterprise_adr-0001.madr.yaml"
936        );
937
938        let decision_with_domain = decision.with_domain("sales");
939        assert_eq!(
940            decision_with_domain.filename("enterprise"),
941            "enterprise_sales_adr-0001.madr.yaml"
942        );
943    }
944
945    #[test]
946    fn test_decision_markdown_filename() {
947        let decision = Decision::new(
948            1,
949            "Use ODCS v3.1.0 for all data contracts",
950            "Context",
951            "Decision",
952        );
953        let filename = decision.markdown_filename();
954        assert!(filename.starts_with("ADR-0001-"));
955        assert!(filename.ends_with(".md"));
956    }
957
958    #[test]
959    fn test_decision_yaml_roundtrip() {
960        let decision = Decision::new(1, "Test Decision", "Some context", "The decision")
961            .with_status(DecisionStatus::Accepted)
962            .with_domain("test");
963
964        let yaml = decision.to_yaml().unwrap();
965        let parsed = Decision::from_yaml(&yaml).unwrap();
966
967        assert_eq!(decision.id, parsed.id);
968        assert_eq!(decision.title, parsed.title);
969        assert_eq!(decision.status, parsed.status);
970        assert_eq!(decision.domain, parsed.domain);
971    }
972
973    #[test]
974    fn test_decision_index() {
975        let mut index = DecisionIndex::new();
976        assert_eq!(index.get_next_number(), 1);
977
978        let decision1 = Decision::new(1, "First", "Context", "Decision");
979        index.add_decision(&decision1, "test_adr-0001.madr.yaml".to_string());
980
981        assert_eq!(index.decisions.len(), 1);
982        assert_eq!(index.get_next_number(), 2);
983
984        let decision2 = Decision::new(2, "Second", "Context", "Decision");
985        index.add_decision(&decision2, "test_adr-0002.madr.yaml".to_string());
986
987        assert_eq!(index.decisions.len(), 2);
988        assert_eq!(index.get_next_number(), 3);
989    }
990
991    #[test]
992    fn test_slugify() {
993        assert_eq!(slugify("Use ODCS v3.1.0"), "use-odcs-v3-1-0");
994        assert_eq!(slugify("Hello World"), "hello-world");
995        assert_eq!(slugify("test--double"), "test-double");
996    }
997
998    #[test]
999    fn test_decision_status_display() {
1000        assert_eq!(format!("{}", DecisionStatus::Proposed), "Proposed");
1001        assert_eq!(format!("{}", DecisionStatus::Accepted), "Accepted");
1002        assert_eq!(format!("{}", DecisionStatus::Deprecated), "Deprecated");
1003        assert_eq!(format!("{}", DecisionStatus::Superseded), "Superseded");
1004        assert_eq!(format!("{}", DecisionStatus::Rejected), "Rejected");
1005    }
1006
1007    #[test]
1008    fn test_asset_link() {
1009        let link = AssetLink::with_relationship(
1010            "odcs",
1011            Uuid::new_v4(),
1012            "orders",
1013            AssetRelationship::Implements,
1014        );
1015
1016        assert_eq!(link.asset_type, "odcs");
1017        assert_eq!(link.asset_name, "orders");
1018        assert_eq!(link.relationship, Some(AssetRelationship::Implements));
1019    }
1020
1021    #[test]
1022    fn test_timestamp_number_generation() {
1023        use chrono::TimeZone;
1024        let dt = Utc.with_ymd_and_hms(2026, 1, 10, 14, 30, 0).unwrap();
1025        let number = Decision::generate_timestamp_number(&dt);
1026        assert_eq!(number, 2601101430);
1027    }
1028
1029    #[test]
1030    fn test_is_timestamp_number() {
1031        let sequential_decision = Decision::new(1, "Test", "Context", "Decision");
1032        assert!(!sequential_decision.is_timestamp_number());
1033
1034        let timestamp_decision = Decision::new(2601101430, "Test", "Context", "Decision");
1035        assert!(timestamp_decision.is_timestamp_number());
1036    }
1037
1038    #[test]
1039    fn test_timestamp_decision_filename() {
1040        let decision = Decision::new(2601101430, "Test", "Context", "Decision");
1041        assert_eq!(
1042            decision.filename("enterprise"),
1043            "enterprise_adr-2601101430.madr.yaml"
1044        );
1045    }
1046
1047    #[test]
1048    fn test_timestamp_decision_markdown_filename() {
1049        let decision = Decision::new(2601101430, "Test Decision", "Context", "Decision");
1050        let filename = decision.markdown_filename();
1051        assert!(filename.starts_with("ADR-2601101430-"));
1052        assert!(filename.ends_with(".md"));
1053    }
1054
1055    #[test]
1056    fn test_decision_with_consulted_informed() {
1057        let decision = Decision::new(1, "Test", "Context", "Decision")
1058            .add_consulted("security@example.com")
1059            .add_informed("stakeholders@example.com");
1060
1061        assert_eq!(decision.consulted.len(), 1);
1062        assert_eq!(decision.informed.len(), 1);
1063        assert_eq!(decision.consulted[0], "security@example.com");
1064        assert_eq!(decision.informed[0], "stakeholders@example.com");
1065    }
1066
1067    #[test]
1068    fn test_decision_with_authors() {
1069        let decision = Decision::new(1, "Test", "Context", "Decision")
1070            .add_author("author1@example.com")
1071            .add_author("author2@example.com");
1072
1073        assert_eq!(decision.authors.len(), 2);
1074    }
1075
1076    #[test]
1077    fn test_decision_index_with_timestamp_numbering() {
1078        let index = DecisionIndex::new_with_timestamp_numbering();
1079        assert!(index.use_timestamp_numbering);
1080
1081        // The next number should be a timestamp
1082        let next = index.get_next_number();
1083        assert!(next >= 1000000000); // Timestamp format check
1084    }
1085
1086    #[test]
1087    fn test_new_categories() {
1088        assert_eq!(format!("{}", DecisionCategory::Data), "Data");
1089        assert_eq!(format!("{}", DecisionCategory::Integration), "Integration");
1090    }
1091
1092    #[test]
1093    fn test_decision_with_related() {
1094        let related_decision_id = Uuid::new_v4();
1095        let related_knowledge_id = Uuid::new_v4();
1096
1097        let decision = Decision::new(1, "Test", "Context", "Decision")
1098            .add_related_decision(related_decision_id)
1099            .add_related_knowledge(related_knowledge_id);
1100
1101        assert_eq!(decision.related_decisions.len(), 1);
1102        assert_eq!(decision.related_knowledge.len(), 1);
1103        assert_eq!(decision.related_decisions[0], related_decision_id);
1104        assert_eq!(decision.related_knowledge[0], related_knowledge_id);
1105    }
1106
1107    #[test]
1108    fn test_decision_status_draft() {
1109        let decision =
1110            Decision::new(1, "Test", "Context", "Decision").with_status(DecisionStatus::Draft);
1111        assert_eq!(decision.status, DecisionStatus::Draft);
1112        assert_eq!(format!("{}", DecisionStatus::Draft), "Draft");
1113    }
1114}