metis_core/domain/documents/
traits.rs1use super::content::DocumentContent;
2use super::metadata::DocumentMetadata;
3use super::types::{DocumentId, DocumentType, Phase, Tag};
4use chrono::Utc;
5
6pub trait Document {
8 fn id(&self) -> DocumentId {
10 DocumentId::from_title(self.title())
11 }
12
13 fn document_type(&self) -> DocumentType;
15
16 fn title(&self) -> &str;
18
19 fn metadata(&self) -> &DocumentMetadata;
21
22 fn content(&self) -> &DocumentContent;
24
25 fn core(&self) -> &DocumentCore;
27
28 fn tags(&self) -> &[Tag] {
30 &self.core().tags
31 }
32
33 fn phase(&self) -> Result<Phase, DocumentValidationError> {
35 for tag in self.tags() {
37 if let Tag::Phase(phase) = tag {
38 return Ok(*phase);
39 }
40 }
41 Err(DocumentValidationError::MissingPhaseTag)
43 }
44
45 fn can_transition_to(&self, phase: Phase) -> bool;
47
48 fn transition_phase(
50 &mut self,
51 target_phase: Option<Phase>,
52 ) -> Result<Phase, DocumentValidationError>;
53
54 fn update_section(
56 &mut self,
57 content: &str,
58 heading: &str,
59 append: bool,
60 ) -> Result<(), DocumentValidationError> {
61 let lines: Vec<&str> = self.core().content.body.lines().collect();
62 let target_heading = format!("## {}", heading);
63
64 let section_start = lines.iter().position(|line| line.trim() == target_heading);
66
67 let new_body = if let Some(section_start) = section_start {
68 let section_end = lines[section_start + 1..]
70 .iter()
71 .position(|line| line.trim_start().starts_with("## "))
72 .map(|pos| section_start + 1 + pos)
73 .unwrap_or(lines.len());
74
75 let mut updated_lines = Vec::new();
77
78 updated_lines.extend_from_slice(&lines[..section_start + 1]);
80
81 if append {
82 updated_lines.extend_from_slice(&lines[section_start + 1..section_end]);
84 if !content.trim().is_empty() {
85 if section_end > section_start + 1 {
86 updated_lines.push(""); }
88 for line in content.lines() {
89 updated_lines.push(line);
90 }
91 }
92 } else {
93 if !content.trim().is_empty() {
95 updated_lines.push(""); for line in content.lines() {
97 updated_lines.push(line);
98 }
99 }
100 }
101
102 if section_end < lines.len() {
104 updated_lines.push(""); updated_lines.extend_from_slice(&lines[section_end..]);
106 }
107
108 updated_lines.join("\n")
109 } else {
110 let mut updated_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
112
113 if !updated_lines.is_empty() {
114 updated_lines.push("".to_string()); }
116 updated_lines.push(target_heading);
117 if !content.trim().is_empty() {
118 updated_lines.push("".to_string()); for line in content.lines() {
120 updated_lines.push(line.to_string());
121 }
122 }
123
124 updated_lines.join("\n")
125 };
126
127 self.update_content_body(new_body)
128 }
129
130 fn update_content_body(&mut self, new_body: String) -> Result<(), DocumentValidationError> {
132 let core = self.core_mut();
134 core.content.body = new_body;
135 core.metadata.updated_at = Utc::now();
136 Ok(())
137 }
138
139 fn core_mut(&mut self) -> &mut DocumentCore;
141
142 fn archived(&self) -> bool {
144 self.core().archived
145 }
146
147 fn parent_id(&self) -> Option<&DocumentId>;
149
150 fn blocked_by(&self) -> &[DocumentId];
152
153 fn validate(&self) -> Result<(), DocumentValidationError>;
155
156 fn exit_criteria_met(&self) -> bool;
158
159 fn template(&self) -> DocumentTemplate;
161
162 fn frontmatter_template(&self) -> &'static str;
164
165 fn content_template(&self) -> &'static str;
167
168 fn acceptance_criteria_template(&self) -> &'static str;
170}
171
172pub struct DocumentTemplate {
174 pub frontmatter: &'static str,
175 pub content: &'static str,
176 pub acceptance_criteria: &'static str,
177 pub file_extension: &'static str,
178}
179
180#[derive(Debug)]
182pub struct DocumentCore {
183 pub title: String,
184 pub metadata: DocumentMetadata,
185 pub content: DocumentContent,
186 pub parent_id: Option<DocumentId>,
187 pub blocked_by: Vec<DocumentId>,
188 pub tags: Vec<Tag>,
189 pub archived: bool,
190}
191
192#[derive(Debug, PartialEq, thiserror::Error)]
194pub enum DocumentValidationError {
195 #[error("Invalid title: {0}")]
196 InvalidTitle(String),
197
198 #[error("Invalid parent: {0}")]
199 InvalidParent(String),
200
201 #[error("Invalid phase transition from {from:?} to {to:?}")]
202 InvalidPhaseTransition { from: Phase, to: Phase },
203
204 #[error("Missing required field: {0}")]
205 MissingRequiredField(String),
206
207 #[error("Invalid content: {0}")]
208 InvalidContent(String),
209
210 #[error("Missing phase tag in document")]
211 MissingPhaseTag,
212}