1use chrono::NaiveDate;
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9#[derive(Debug, Error)]
10pub enum DocError {
11 #[error("Invalid document format: {0}")]
12 InvalidFormat(String),
13
14 #[error("Missing required field: {0}")]
15 MissingField(String),
16
17 #[error("Invalid date format: {0}")]
18 InvalidDate(String),
19
20 #[error("Invalid state: {0}")]
21 InvalidState(String),
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
26pub enum DocState {
27 Draft,
28 UnderReview,
29 Revised,
30 Accepted,
31 Active,
32 Final,
33 Deferred,
34 Rejected,
35 Withdrawn,
36 Superseded,
37 Removed, Overwritten, }
40
41impl DocState {
42 pub fn as_str(&self) -> &'static str {
44 match self {
45 DocState::Draft => "Draft",
46 DocState::UnderReview => "Under Review",
47 DocState::Revised => "Revised",
48 DocState::Accepted => "Accepted",
49 DocState::Active => "Active",
50 DocState::Final => "Final",
51 DocState::Deferred => "Deferred",
52 DocState::Rejected => "Rejected",
53 DocState::Withdrawn => "Withdrawn",
54 DocState::Superseded => "Superseded",
55 DocState::Removed => "Removed",
56 DocState::Overwritten => "Overwritten",
57 }
58 }
59
60 pub fn directory(&self) -> &'static str {
62 match self {
63 DocState::Draft => "01-draft",
64 DocState::UnderReview => "02-under-review",
65 DocState::Revised => "03-revised",
66 DocState::Accepted => "04-accepted",
67 DocState::Active => "05-active",
68 DocState::Final => "06-final",
69 DocState::Deferred => "07-deferred",
70 DocState::Rejected => "08-rejected",
71 DocState::Withdrawn => "09-withdrawn",
72 DocState::Superseded => "10-superseded",
73 DocState::Removed => ".dustbin",
76 DocState::Overwritten => ".dustbin/overwritten",
77 }
78 }
79
80 pub fn is_in_dustbin(&self) -> bool {
82 matches!(self, DocState::Removed | DocState::Overwritten)
83 }
84
85 pub fn from_str_flexible(s: &str) -> Option<Self> {
87 let normalized = s.to_lowercase().replace(['-', '_'], " ");
88 let normalized = normalized.trim();
89 match normalized {
90 "draft" => Some(DocState::Draft),
91 "under review" | "review" | "underreview" => Some(DocState::UnderReview),
92 "revised" => Some(DocState::Revised),
93 "accepted" => Some(DocState::Accepted),
94 "active" => Some(DocState::Active),
95 "final" => Some(DocState::Final),
96 "deferred" => Some(DocState::Deferred),
97 "rejected" => Some(DocState::Rejected),
98 "withdrawn" => Some(DocState::Withdrawn),
99 "superseded" => Some(DocState::Superseded),
100 "removed" => Some(DocState::Removed),
101 "overwritten" => Some(DocState::Overwritten),
102 _ => None,
103 }
104 }
105
106 pub fn from_directory(dir: &str) -> Option<Self> {
108 match dir {
109 "01-draft" | "01-drafts" => Some(DocState::Draft),
110 "02-under-review" => Some(DocState::UnderReview),
111 "03-revised" => Some(DocState::Revised),
112 "04-accepted" => Some(DocState::Accepted),
113 "05-active" => Some(DocState::Active),
114 "06-final" | "03-final" => Some(DocState::Final),
115 "07-deferred" => Some(DocState::Deferred),
116 "08-rejected" => Some(DocState::Rejected),
117 "09-withdrawn" => Some(DocState::Withdrawn),
118 "10-superseded" | "04-superseded" => Some(DocState::Superseded),
119 ".dustbin" => Some(DocState::Removed),
120 ".dustbin/overwritten" => Some(DocState::Overwritten),
121 _ => None,
122 }
123 }
124
125 pub fn all_states() -> Vec<DocState> {
127 vec![
128 DocState::Draft,
129 DocState::UnderReview,
130 DocState::Revised,
131 DocState::Accepted,
132 DocState::Active,
133 DocState::Final,
134 DocState::Deferred,
135 DocState::Rejected,
136 DocState::Withdrawn,
137 DocState::Superseded,
138 DocState::Removed,
139 DocState::Overwritten,
140 ]
141 }
142
143 pub fn all_state_names() -> Vec<&'static str> {
145 Self::all_states().iter().map(|s| s.as_str()).collect()
146 }
147
148 pub fn description(&self) -> &'static str {
150 match self {
151 DocState::Draft => "Initial state for new documents",
152 DocState::UnderReview => "Document is being reviewed",
153 DocState::Revised => "Document has been revised after review",
154 DocState::Accepted => "Document has been accepted",
155 DocState::Active => "Document is actively being implemented",
156 DocState::Final => "Document is complete and final",
157 DocState::Deferred => "Document is deferred for future consideration",
158 DocState::Rejected => "Document has been rejected",
159 DocState::Withdrawn => "Document has been withdrawn by author",
160 DocState::Superseded => "Document has been replaced by a newer version",
161 DocState::Removed => "Document has been removed from active use",
162 DocState::Overwritten => "Document was replaced via 'oxd replace'",
163 }
164 }
165}
166
167impl<'de> Deserialize<'de> for DocState {
168 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
169 where
170 D: serde::Deserializer<'de>,
171 {
172 let s = String::deserialize(deserializer)?;
173 DocState::from_str_flexible(&s)
174 .ok_or_else(|| serde::de::Error::custom(format!("Invalid state: {}", s)))
175 }
176}
177
178fn deserialize_version<'de, D>(deserializer: D) -> Result<String, D::Error>
180where
181 D: serde::Deserializer<'de>,
182{
183 use serde::de::Deserialize;
184 let s = String::deserialize(deserializer)?;
185
186 let parts: Vec<&str> = s.split('.').collect();
188
189 if parts.len() != 2 {
190 return Err(serde::de::Error::custom(format!(
191 "Version must be in major.minor format (e.g., '1.0'), got: '{}'",
192 s
193 )));
194 }
195
196 for (idx, part) in parts.iter().enumerate() {
198 if part.parse::<u32>().is_err() {
199 let label = if idx == 0 { "major" } else { "minor" };
200 return Err(serde::de::Error::custom(format!(
201 "Invalid {} version number: '{}' in '{}'",
202 label, part, s
203 )));
204 }
205 }
206
207 Ok(s)
208}
209
210pub fn parse_version(version: &str) -> Result<(u32, u32), String> {
212 let parts: Vec<&str> = version.split('.').collect();
213
214 if parts.len() != 2 {
215 return Err(format!("Invalid version format: '{}'. Expected 'major.minor'", version));
216 }
217
218 let major =
219 parts[0].parse::<u32>().map_err(|_| format!("Invalid major version: '{}'", parts[0]))?;
220 let minor =
221 parts[1].parse::<u32>().map_err(|_| format!("Invalid minor version: '{}'", parts[1]))?;
222
223 Ok((major, minor))
224}
225
226pub fn increment_minor_version(version: &str) -> Result<String, String> {
228 let (major, minor) = parse_version(version)?;
229 Ok(format!("{}.{}", major, minor + 1))
230}
231
232pub fn is_version_valid_upgrade(old_version: &str, new_version: &str) -> Result<bool, String> {
234 let (old_major, old_minor) = parse_version(old_version)?;
235 let (new_major, new_minor) = parse_version(new_version)?;
236
237 Ok(new_major > old_major || (new_major == old_major && new_minor >= old_minor))
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct DocMetadata {
243 pub number: u32,
244 pub title: String,
245 pub author: String,
246 #[serde(skip_serializing_if = "Option::is_none")]
247 pub component: Option<String>,
248 #[serde(default)]
249 pub tags: Vec<String>,
250 pub created: NaiveDate,
251 pub updated: NaiveDate,
252 pub state: DocState,
253 pub supersedes: Option<u32>,
254 #[serde(rename = "superseded-by")]
255 pub superseded_by: Option<u32>,
256 #[serde(default = "default_version", deserialize_with = "deserialize_version")]
257 pub version: String,
258}
259
260fn default_version() -> String {
261 "1.0".to_string()
262}
263
264#[derive(Debug, Clone)]
266pub struct DesignDoc {
267 pub metadata: DocMetadata,
268 pub content: String,
269 pub path: PathBuf,
270}
271
272impl DesignDoc {
273 pub fn parse(content: &str, path: PathBuf) -> Result<Self, DocError> {
275 let parts: Vec<&str> = content.splitn(3, "---").collect();
277
278 if parts.len() < 3 {
279 return Err(DocError::InvalidFormat("Missing YAML frontmatter".to_string()));
280 }
281
282 let frontmatter = parts[1].trim();
283 let body = parts[2].trim();
284
285 let metadata: DocMetadata = serde_yaml::from_str(frontmatter)
287 .map_err(|e| DocError::InvalidFormat(format!("YAML parse error: {}", e)))?;
288
289 Ok(DesignDoc { metadata, content: body.to_string(), path })
290 }
291
292 pub fn filename(&self) -> String {
294 format!(
295 "{:04}-{}.md",
296 self.metadata.number,
297 self.metadata
298 .title
299 .to_lowercase()
300 .replace(' ', "-")
301 .chars()
302 .filter(|c| c.is_alphanumeric() || *c == '-')
303 .collect::<String>()
304 )
305 }
306
307 pub fn update_yaml_field(content: &str, field: &str, value: &str) -> Result<String, DocError> {
309 let pattern = format!(r"(?m)^{}: .*$", regex::escape(field));
310 let re = Regex::new(&pattern)
311 .map_err(|e| DocError::InvalidFormat(format!("Regex error: {}", e)))?;
312
313 let replacement = format!("{}: {}", field, value);
314 Ok(re.replace(content, replacement.as_str()).to_string())
315 }
316
317 pub fn update_state(content: &str, new_state: DocState) -> Result<String, DocError> {
319 let today = chrono::Local::now().naive_local().date();
320
321 let mut updated = Self::update_yaml_field(content, "state", new_state.as_str())?;
322 updated = Self::update_yaml_field(&updated, "updated", &today.to_string())?;
323
324 Ok(updated)
325 }
326}
327
328fn escape_yaml_string(s: &str) -> String {
330 s.replace('\\', "\\\\") .replace('"', "\\\"") }
333
334pub fn build_yaml_frontmatter(metadata: &DocMetadata) -> String {
336 let mut yaml = String::from("---\n");
337
338 yaml.push_str(&format!("number: {}\n", metadata.number));
340 yaml.push_str(&format!("title: \"{}\"\n", escape_yaml_string(&metadata.title)));
341 yaml.push_str(&format!("author: \"{}\"\n", escape_yaml_string(&metadata.author)));
342
343 if let Some(component) = &metadata.component {
345 yaml.push_str(&format!("component: {}\n", component));
346 }
347
348 if !metadata.tags.is_empty() {
350 yaml.push_str(&format!("tags: [{}]\n", metadata.tags.join(", ")));
351 }
352
353 yaml.push_str(&format!("created: {}\n", metadata.created));
355 yaml.push_str(&format!("updated: {}\n", metadata.updated));
356 yaml.push_str(&format!("state: {}\n", metadata.state.as_str()));
357
358 if let Some(supersedes) = metadata.supersedes {
360 yaml.push_str(&format!("supersedes: {}\n", supersedes));
361 } else {
362 yaml.push_str("supersedes: null\n");
363 }
364
365 if let Some(superseded_by) = metadata.superseded_by {
366 yaml.push_str(&format!("superseded-by: {}\n", superseded_by));
367 } else {
368 yaml.push_str("superseded-by: null\n");
369 }
370
371 yaml.push_str(&format!("version: {}\n", metadata.version));
373
374 yaml.push_str("---\n\n");
375 yaml
376}
377
378pub fn extract_title_from_content(content: &str, filename: &str) -> String {
380 for line in content.lines() {
382 let trimmed = line.trim();
383 if let Some(title) = trimmed.strip_prefix("# ") {
384 return title.trim().to_string();
385 }
386 }
387
388 let re = Regex::new(r"^\d+-(.+)\.md$").unwrap();
390 if let Some(caps) = re.captures(filename) {
391 if let Some(slug) = caps.get(1) {
392 return slug
393 .as_str()
394 .split('-')
395 .map(|word| {
396 let mut chars = word.chars();
397 match chars.next() {
398 None => String::new(),
399 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
400 }
401 })
402 .collect::<Vec<_>>()
403 .join(" ");
404 }
405 }
406
407 "Untitled Document".to_string()
408}
409
410pub fn extract_number_from_filename(filename: &str) -> u32 {
412 let re = Regex::new(r"^(\d+)-").unwrap();
413 if let Some(caps) = re.captures(filename) {
414 if let Some(num) = caps.get(1) {
415 return num.as_str().parse().unwrap_or(0);
416 }
417 }
418 0
419}
420
421pub fn add_missing_headers(
423 path: impl AsRef<Path>,
424 content: &str,
425) -> Result<(String, Vec<String>), DocError> {
426 use crate::git;
427
428 let path = path.as_ref();
429 let filename = path
430 .file_name()
431 .and_then(|n| n.to_str())
432 .ok_or_else(|| DocError::InvalidFormat("Invalid filename".to_string()))?;
433
434 let number = extract_number_from_filename(filename);
436 let title = extract_title_from_content(content, filename);
437 let author = git::get_author(path);
438 let created = git::get_created_date(path);
439 let updated = git::get_updated_date(path);
440
441 let mut added_fields = Vec::new();
442
443 if content.trim_start().starts_with("---") {
445 match DesignDoc::parse(content, path.to_path_buf()) {
447 Ok(doc) => {
448 let mut metadata = doc.metadata;
450
451 if metadata.number == 0 && number > 0 {
452 metadata.number = number;
453 added_fields.push("number".to_string());
454 }
455 if metadata.title.is_empty() || metadata.title == "Untitled Document" {
456 metadata.title = title;
457 added_fields.push("title".to_string());
458 }
459 if metadata.author.is_empty() || metadata.author == "Unknown Author" {
460 metadata.author = author;
461 added_fields.push("author".to_string());
462 }
463
464 let re = Regex::new(r"(?s)^---\n.*?\n---\n*").unwrap();
466 let body = re.replace(content, "");
467 let new_content = build_yaml_frontmatter(&metadata) + body.trim_start();
468
469 Ok((new_content, added_fields))
470 }
471 Err(_) => {
472 let metadata = DocMetadata {
474 number,
475 title,
476 author,
477 component: None,
478 tags: Vec::new(),
479 created,
480 updated,
481 state: DocState::Draft,
482 supersedes: None,
483 superseded_by: None,
484 version: "1.0".to_string(),
485 };
486 added_fields = [
487 "number",
488 "title",
489 "author",
490 "created",
491 "updated",
492 "state",
493 "supersedes",
494 "superseded-by",
495 "version",
496 ]
497 .iter()
498 .map(|s| s.to_string())
499 .collect();
500
501 let re = Regex::new(r"(?s)^---\n.*?\n---\n*").unwrap();
503 let body = re.replace(content, "");
504 let new_content = build_yaml_frontmatter(&metadata) + body.trim_start();
505 Ok((new_content, added_fields))
506 }
507 }
508 } else {
509 let metadata = DocMetadata {
511 number,
512 title,
513 author,
514 component: None,
515 tags: Vec::new(),
516 created,
517 updated,
518 state: DocState::Draft,
519 supersedes: None,
520 superseded_by: None,
521 version: "1.0".to_string(),
522 };
523
524 added_fields = [
525 "number",
526 "title",
527 "author",
528 "created",
529 "updated",
530 "state",
531 "supersedes",
532 "superseded-by",
533 "version",
534 ]
535 .iter()
536 .map(|s| s.to_string())
537 .collect();
538
539 let new_content = build_yaml_frontmatter(&metadata) + content;
540 Ok((new_content, added_fields))
541 }
542}
543
544pub fn has_number_prefix(filename: &str) -> bool {
550 let re = Regex::new(r"^\d{4}-").unwrap();
551 re.is_match(filename)
552}
553
554pub fn add_number_prefix(path: &Path, number: u32) -> Result<PathBuf, std::io::Error> {
556 let filename = path
557 .file_name()
558 .and_then(|n| n.to_str())
559 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid filename"))?;
560
561 let new_filename = format!("{:04}-{}", number, filename);
562 let new_path = path.with_file_name(new_filename);
563
564 std::fs::rename(path, &new_path)?;
565
566 Ok(new_path)
567}
568
569pub fn is_in_project_dir(file_path: &Path, project_dir: &Path) -> Result<bool, std::io::Error> {
575 let abs_file = file_path.canonicalize()?;
576 let abs_project = project_dir.canonicalize()?;
577
578 Ok(abs_file.starts_with(abs_project))
579}
580
581pub fn is_in_state_dir(file_path: &Path) -> bool {
583 if let Some(parent) = file_path.parent() {
584 if let Some(dir_name) = parent.file_name().and_then(|n| n.to_str()) {
585 return DocState::from_directory(dir_name).is_some();
586 }
587 }
588 false
589}
590
591pub fn state_from_directory(file_path: &Path) -> Option<DocState> {
593 file_path
594 .parent()
595 .and_then(|p| p.file_name())
596 .and_then(|n| n.to_str())
597 .and_then(DocState::from_directory)
598}
599
600pub fn move_to_project(file_path: &Path, project_dir: &Path) -> Result<PathBuf, std::io::Error> {
602 let filename = file_path
603 .file_name()
604 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid filename"))?;
605
606 let new_path = project_dir.join(filename);
607 std::fs::rename(file_path, &new_path)?;
608
609 Ok(new_path)
610}
611
612pub fn move_to_state_dir(
614 file_path: &Path,
615 state: DocState,
616 project_dir: &Path,
617) -> Result<PathBuf, std::io::Error> {
618 let filename = file_path
619 .file_name()
620 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid filename"))?;
621
622 let state_dir = project_dir.join(state.directory());
623 std::fs::create_dir_all(&state_dir)?;
624
625 let new_path = state_dir.join(filename);
626 std::fs::rename(file_path, &new_path)?;
627
628 Ok(new_path)
629}
630
631pub fn has_frontmatter(content: &str) -> bool {
637 content.trim_start().starts_with("---\n")
638}
639
640pub fn has_placeholder_values(content: &str) -> bool {
642 content.contains("number: NNNN")
643 || content.contains("number: 0\n")
644 || content.contains("author: Unknown")
645 || content.contains("title: \"\"")
646}
647
648pub fn ensure_valid_headers(path: &Path, content: &str) -> Result<String, DocError> {
650 if !has_frontmatter(content) || has_placeholder_values(content) {
651 let (new_content, _) = add_missing_headers(path, content)?;
652 Ok(new_content)
653 } else {
654 Ok(content.to_string())
655 }
656}
657
658pub fn sync_state_with_directory(path: &Path, content: &str) -> Result<String, DocError> {
664 let dir_state = state_from_directory(path)
666 .ok_or_else(|| DocError::InvalidFormat("Document not in a state directory".to_string()))?;
667
668 let doc = DesignDoc::parse(content, path.to_path_buf())?;
670
671 if doc.metadata.state != dir_state {
673 DesignDoc::update_state(content, dir_state)
674 } else {
675 Ok(content.to_string())
676 }
677}
678
679#[cfg(test)]
680mod docstate_tests {
681 use super::*;
682
683 #[test]
684 fn test_as_str_all_states() {
685 assert_eq!(DocState::Draft.as_str(), "Draft");
686 assert_eq!(DocState::UnderReview.as_str(), "Under Review");
687 assert_eq!(DocState::Revised.as_str(), "Revised");
688 assert_eq!(DocState::Accepted.as_str(), "Accepted");
689 assert_eq!(DocState::Active.as_str(), "Active");
690 assert_eq!(DocState::Final.as_str(), "Final");
691 assert_eq!(DocState::Deferred.as_str(), "Deferred");
692 assert_eq!(DocState::Rejected.as_str(), "Rejected");
693 assert_eq!(DocState::Withdrawn.as_str(), "Withdrawn");
694 assert_eq!(DocState::Superseded.as_str(), "Superseded");
695 }
696
697 #[test]
698 fn test_directory_all_states() {
699 assert_eq!(DocState::Draft.directory(), "01-draft");
700 assert_eq!(DocState::UnderReview.directory(), "02-under-review");
701 assert_eq!(DocState::Revised.directory(), "03-revised");
702 assert_eq!(DocState::Accepted.directory(), "04-accepted");
703 assert_eq!(DocState::Active.directory(), "05-active");
704 assert_eq!(DocState::Final.directory(), "06-final");
705 assert_eq!(DocState::Deferred.directory(), "07-deferred");
706 assert_eq!(DocState::Rejected.directory(), "08-rejected");
707 assert_eq!(DocState::Withdrawn.directory(), "09-withdrawn");
708 assert_eq!(DocState::Superseded.directory(), "10-superseded");
709 }
710
711 #[test]
712 fn test_from_str_flexible_canonical() {
713 assert_eq!(DocState::from_str_flexible("draft"), Some(DocState::Draft));
714 assert_eq!(DocState::from_str_flexible("under review"), Some(DocState::UnderReview));
715 assert_eq!(DocState::from_str_flexible("revised"), Some(DocState::Revised));
716 assert_eq!(DocState::from_str_flexible("accepted"), Some(DocState::Accepted));
717 assert_eq!(DocState::from_str_flexible("active"), Some(DocState::Active));
718 assert_eq!(DocState::from_str_flexible("final"), Some(DocState::Final));
719 assert_eq!(DocState::from_str_flexible("deferred"), Some(DocState::Deferred));
720 assert_eq!(DocState::from_str_flexible("rejected"), Some(DocState::Rejected));
721 assert_eq!(DocState::from_str_flexible("withdrawn"), Some(DocState::Withdrawn));
722 assert_eq!(DocState::from_str_flexible("superseded"), Some(DocState::Superseded));
723 }
724
725 #[test]
726 fn test_from_str_flexible_case_insensitive() {
727 assert_eq!(DocState::from_str_flexible("DRAFT"), Some(DocState::Draft));
728 assert_eq!(DocState::from_str_flexible("Draft"), Some(DocState::Draft));
729 assert_eq!(DocState::from_str_flexible("DRaFT"), Some(DocState::Draft));
730 assert_eq!(DocState::from_str_flexible("UNDER REVIEW"), Some(DocState::UnderReview));
731 }
732
733 #[test]
734 fn test_from_str_flexible_aliases() {
735 assert_eq!(DocState::from_str_flexible("review"), Some(DocState::UnderReview));
736 assert_eq!(DocState::from_str_flexible("underreview"), Some(DocState::UnderReview));
737 }
738
739 #[test]
740 fn test_from_str_flexible_with_hyphens() {
741 assert_eq!(DocState::from_str_flexible("under-review"), Some(DocState::UnderReview));
742 assert_eq!(DocState::from_str_flexible("under_review"), Some(DocState::UnderReview));
743 }
744
745 #[test]
746 fn test_from_str_flexible_whitespace() {
747 assert_eq!(DocState::from_str_flexible(" draft "), Some(DocState::Draft));
748 assert_eq!(DocState::from_str_flexible(" under review "), Some(DocState::UnderReview));
749 }
750
751 #[test]
752 fn test_from_str_flexible_invalid() {
753 assert_eq!(DocState::from_str_flexible("invalid"), None);
754 assert_eq!(DocState::from_str_flexible(""), None);
755 assert_eq!(DocState::from_str_flexible("pending"), None);
756 }
757
758 #[test]
759 fn test_from_directory_canonical() {
760 assert_eq!(DocState::from_directory("01-draft"), Some(DocState::Draft));
761 assert_eq!(DocState::from_directory("02-under-review"), Some(DocState::UnderReview));
762 assert_eq!(DocState::from_directory("03-revised"), Some(DocState::Revised));
763 assert_eq!(DocState::from_directory("04-accepted"), Some(DocState::Accepted));
764 assert_eq!(DocState::from_directory("05-active"), Some(DocState::Active));
765 assert_eq!(DocState::from_directory("06-final"), Some(DocState::Final));
766 assert_eq!(DocState::from_directory("07-deferred"), Some(DocState::Deferred));
767 assert_eq!(DocState::from_directory("08-rejected"), Some(DocState::Rejected));
768 assert_eq!(DocState::from_directory("09-withdrawn"), Some(DocState::Withdrawn));
769 assert_eq!(DocState::from_directory("10-superseded"), Some(DocState::Superseded));
770 }
771
772 #[test]
773 fn test_from_directory_legacy() {
774 assert_eq!(DocState::from_directory("01-drafts"), Some(DocState::Draft));
776 assert_eq!(DocState::from_directory("03-final"), Some(DocState::Final));
777 assert_eq!(DocState::from_directory("04-superseded"), Some(DocState::Superseded));
778 }
779
780 #[test]
781 fn test_from_directory_invalid() {
782 assert_eq!(DocState::from_directory("invalid"), None);
783 assert_eq!(DocState::from_directory("11-unknown"), None);
784 assert_eq!(DocState::from_directory("draft"), None);
785 }
786
787 #[test]
788 fn test_all_states_count() {
789 let states = DocState::all_states();
790 assert_eq!(states.len(), 12);
791 }
792
793 #[test]
794 fn test_all_states_complete() {
795 let states = DocState::all_states();
796 assert!(states.contains(&DocState::Draft));
797 assert!(states.contains(&DocState::UnderReview));
798 assert!(states.contains(&DocState::Revised));
799 assert!(states.contains(&DocState::Accepted));
800 assert!(states.contains(&DocState::Active));
801 assert!(states.contains(&DocState::Final));
802 assert!(states.contains(&DocState::Deferred));
803 assert!(states.contains(&DocState::Rejected));
804 assert!(states.contains(&DocState::Withdrawn));
805 assert!(states.contains(&DocState::Superseded));
806 }
807
808 #[test]
809 fn test_all_state_names() {
810 let names = DocState::all_state_names();
811 assert_eq!(names.len(), 12);
812 assert!(names.contains(&"Draft"));
813 assert!(names.contains(&"Under Review"));
814 assert!(names.contains(&"Final"));
815 }
816
817 #[test]
818 fn test_serde_serialization() {
819 let state = DocState::Draft;
820 let json = serde_json::to_string(&state).unwrap();
821 assert_eq!(json, "\"Draft\"");
822 }
823
824 #[test]
825 fn test_serde_deserialization_valid() {
826 let json = "\"Draft\"";
827 let state: DocState = serde_json::from_str(json).unwrap();
828 assert_eq!(state, DocState::Draft);
829
830 let json = "\"under review\"";
831 let state: DocState = serde_json::from_str(json).unwrap();
832 assert_eq!(state, DocState::UnderReview);
833 }
834
835 #[test]
836 fn test_serde_deserialization_invalid() {
837 let json = "\"invalid state\"";
838 let result: Result<DocState, _> = serde_json::from_str(json);
839 assert!(result.is_err());
840 }
841
842 #[test]
843 fn test_state_equality() {
844 assert_eq!(DocState::Draft, DocState::Draft);
845 assert_ne!(DocState::Draft, DocState::Final);
846 }
847
848 #[test]
849 fn test_state_round_trip() {
850 for state in DocState::all_states() {
851 let str_repr = state.as_str();
853 assert_eq!(DocState::from_str_flexible(str_repr), Some(state));
854
855 let dir_repr = state.directory();
857 assert_eq!(DocState::from_directory(dir_repr), Some(state));
858 }
859 }
860}
861
862#[cfg(test)]
863mod parsing_tests {
864 use super::*;
865 use chrono::NaiveDate;
866
867 fn create_test_doc_content(state: &str) -> String {
868 format!(
869 "---\nnumber: 42\ntitle: \"Test Document\"\nauthor: \"Test Author\"\ncreated: 2024-01-01\nupdated: 2024-01-02\nstate: {}\nsupersedes: null\nsuperseded-by: null\n---\n\n# Test Document\n\nThis is the content.",
870 state
871 )
872 }
873
874 #[test]
875 fn test_parse_valid_document() {
876 let content = create_test_doc_content("Draft");
877 let result = DesignDoc::parse(&content, PathBuf::from("test.md"));
878
879 assert!(result.is_ok());
880 let doc = result.unwrap();
881 assert_eq!(doc.metadata.number, 42);
882 assert_eq!(doc.metadata.title, "Test Document");
883 assert_eq!(doc.metadata.author, "Test Author");
884 assert_eq!(doc.metadata.state, DocState::Draft);
885 assert!(doc.content.contains("# Test Document"));
886 }
887
888 #[test]
889 fn test_parse_all_states() {
890 for state in DocState::all_states() {
891 let content = create_test_doc_content(state.as_str());
892 let result = DesignDoc::parse(&content, PathBuf::from("test.md"));
893
894 assert!(result.is_ok());
895 let doc = result.unwrap();
896 assert_eq!(doc.metadata.state, state);
897 }
898 }
899
900 #[test]
901 fn test_parse_missing_frontmatter() {
902 let content = "# Just Content\n\nNo frontmatter here.";
903 let result = DesignDoc::parse(content, PathBuf::from("test.md"));
904
905 assert!(result.is_err());
906 match result {
907 Err(DocError::InvalidFormat(msg)) => assert!(msg.contains("Missing YAML frontmatter")),
908 _ => panic!("Expected InvalidFormat error"),
909 }
910 }
911
912 #[test]
913 fn test_parse_malformed_yaml() {
914 let content = "---\nthis is not yaml\njust random text\n---\n\nContent";
915 let result = DesignDoc::parse(content, PathBuf::from("test.md"));
916
917 assert!(result.is_err());
918 match result {
919 Err(DocError::InvalidFormat(msg)) => assert!(msg.contains("YAML parse error")),
920 _ => panic!("Expected InvalidFormat error"),
921 }
922 }
923
924 #[test]
925 fn test_parse_with_supersedes() {
926 let content = "---\nnumber: 42\ntitle: \"Test\"\nauthor: \"Author\"\ncreated: 2024-01-01\nupdated: 2024-01-02\nstate: Final\nsupersedes: 41\nsuperseded-by: null\n---\n\nContent";
927 let result = DesignDoc::parse(content, PathBuf::from("test.md"));
928
929 assert!(result.is_ok());
930 let doc = result.unwrap();
931 assert_eq!(doc.metadata.supersedes, Some(41));
932 assert_eq!(doc.metadata.superseded_by, None);
933 }
934
935 #[test]
936 fn test_parse_with_superseded_by() {
937 let content = "---\nnumber: 41\ntitle: \"Test\"\nauthor: \"Author\"\ncreated: 2024-01-01\nupdated: 2024-01-02\nstate: Superseded\nsupersedes: null\nsuperseded-by: 42\n---\n\nContent";
938 let result = DesignDoc::parse(content, PathBuf::from("test.md"));
939
940 assert!(result.is_ok());
941 let doc = result.unwrap();
942 assert_eq!(doc.metadata.supersedes, None);
943 assert_eq!(doc.metadata.superseded_by, Some(42));
944 }
945
946 #[test]
947 fn test_filename_generation() {
948 let metadata = DocMetadata {
949 number: 42,
950 title: "My Cool Feature".to_string(),
951 author: "Author".to_string(),
952 component: None,
953 tags: Vec::new(),
954 created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
955 updated: NaiveDate::from_ymd_opt(2024, 1, 2).unwrap(),
956 state: DocState::Draft,
957 supersedes: None,
958 superseded_by: None,
959 version: "1.0".to_string(),
960 };
961 let doc =
962 DesignDoc { metadata, content: "test".to_string(), path: PathBuf::from("test.md") };
963
964 assert_eq!(doc.filename(), "0042-my-cool-feature.md");
965 }
966
967 #[test]
968 fn test_filename_special_chars() {
969 let metadata = DocMetadata {
970 number: 1,
971 title: "Test!!! Document???".to_string(),
972 author: "Author".to_string(),
973 component: None,
974 tags: Vec::new(),
975 created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
976 updated: NaiveDate::from_ymd_opt(2024, 1, 2).unwrap(),
977 state: DocState::Draft,
978 supersedes: None,
979 superseded_by: None,
980 version: "1.0".to_string(),
981 };
982 let doc =
983 DesignDoc { metadata, content: "test".to_string(), path: PathBuf::from("test.md") };
984
985 assert_eq!(doc.filename(), "0001-test-document.md");
986 }
987
988 #[test]
989 fn test_extract_title_from_heading() {
990 let content = "# Main Title\n\nSome content here.";
991 let title = extract_title_from_content(content, "0001-test.md");
992 assert_eq!(title, "Main Title");
993 }
994
995 #[test]
996 fn test_extract_title_from_filename() {
997 let content = "No headings here.";
998 let title = extract_title_from_content(content, "0042-my-feature.md");
999 assert_eq!(title, "My Feature");
1000 }
1001
1002 #[test]
1003 fn test_extract_title_fallback() {
1004 let content = "No headings here.";
1005 let title = extract_title_from_content(content, "invalid-filename");
1006 assert_eq!(title, "Untitled Document");
1007 }
1008
1009 #[test]
1010 fn test_extract_title_empty_word_in_filename() {
1011 let content = "No headings here.";
1012 let title = extract_title_from_content(content, "0042-test--double-dash.md");
1013 assert_eq!(title, "Test Double Dash");
1014 }
1015
1016 #[test]
1017 fn test_extract_title_single_char_words() {
1018 let content = "No headings here.";
1019 let title = extract_title_from_content(content, "0042-a-b-c.md");
1020 assert_eq!(title, "A B C");
1021 }
1022
1023 #[test]
1024 fn test_extract_title_with_whitespace_heading() {
1025 let content = " # Title With Spaces \n\nContent";
1026 let title = extract_title_from_content(content, "0042-test.md");
1027 assert_eq!(title, "Title With Spaces");
1028 }
1029
1030 #[test]
1031 fn test_extract_title_filename_with_empty_segments() {
1032 let content = "No headings here.";
1033 let title = extract_title_from_content(content, "0042-test--extra.md");
1034 assert!(title.contains("Test") && title.contains("Extra"));
1036 }
1037
1038 #[test]
1039 fn test_extract_number_from_filename() {
1040 assert_eq!(extract_number_from_filename("0001-test.md"), 1);
1041 assert_eq!(extract_number_from_filename("0042-feature.md"), 42);
1042 assert_eq!(extract_number_from_filename("9999-doc.md"), 9999);
1043 }
1044
1045 #[test]
1046 fn test_extract_number_no_prefix() {
1047 assert_eq!(extract_number_from_filename("test.md"), 0);
1048 assert_eq!(extract_number_from_filename("no-number.md"), 0);
1049 }
1050
1051 #[test]
1052 fn test_extract_number_invalid_parse() {
1053 assert_eq!(extract_number_from_filename("999999999999999999999-test.md"), 0);
1054 }
1055
1056 #[test]
1057 fn test_has_number_prefix() {
1058 assert!(has_number_prefix("0001-test.md"));
1059 assert!(has_number_prefix("9999-doc.md"));
1060 assert!(!has_number_prefix("test.md"));
1061 assert!(!has_number_prefix("001-short.md"));
1062 }
1063
1064 #[test]
1065 fn test_has_frontmatter() {
1066 assert!(has_frontmatter("---\ntitle: Test\n---\nContent"));
1067 assert!(has_frontmatter(" ---\ntitle: Test\n---\nContent"));
1068 assert!(!has_frontmatter("# No frontmatter"));
1069 assert!(!has_frontmatter(""));
1070 }
1071
1072 #[test]
1073 fn test_has_placeholder_values() {
1074 assert!(has_placeholder_values("number: NNNN\ntitle: Test"));
1075 assert!(has_placeholder_values("number: 0\ntitle: Test"));
1076 assert!(has_placeholder_values("author: Unknown\ntitle: Test"));
1077 assert!(has_placeholder_values("title: \"\""));
1078 assert!(!has_placeholder_values("number: 42\ntitle: Real Title\nauthor: Real Author"));
1079 }
1080}
1081
1082#[cfg(test)]
1083mod frontmatter_tests {
1084 use super::*;
1085 use chrono::NaiveDate;
1086
1087 #[test]
1088 fn test_build_yaml_frontmatter_complete() {
1089 let metadata = DocMetadata {
1090 number: 42,
1091 title: "Test Document".to_string(),
1092 author: "Test Author".to_string(),
1093 component: None,
1094 tags: Vec::new(),
1095 created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1096 updated: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1097 state: DocState::Draft,
1098 supersedes: Some(41),
1099 superseded_by: Some(43),
1100 version: "1.0".to_string(),
1101 };
1102
1103 let yaml = build_yaml_frontmatter(&metadata);
1104
1105 assert!(yaml.starts_with("---\n"));
1106 assert!(yaml.contains("number: 42\n"));
1107 assert!(yaml.contains("title: \"Test Document\"\n"));
1108 assert!(yaml.contains("author: \"Test Author\"\n"));
1109 assert!(yaml.contains("state: Draft\n"));
1110 assert!(yaml.contains("supersedes: 41\n"));
1111 assert!(yaml.contains("superseded-by: 43\n"));
1112 assert!(yaml.contains("version: 1.0\n"));
1113 assert!(yaml.ends_with("---\n\n"));
1114 }
1115
1116 #[test]
1117 fn test_build_yaml_frontmatter_nulls() {
1118 let metadata = DocMetadata {
1119 number: 1,
1120 title: "Test".to_string(),
1121 author: "Author".to_string(),
1122 component: None,
1123 tags: Vec::new(),
1124 created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1125 updated: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1126 state: DocState::Draft,
1127 supersedes: None,
1128 superseded_by: None,
1129 version: "1.0".to_string(),
1130 };
1131
1132 let yaml = build_yaml_frontmatter(&metadata);
1133
1134 assert!(yaml.contains("supersedes: null\n"));
1135 assert!(yaml.contains("superseded-by: null\n"));
1136 }
1137
1138 #[test]
1139 fn test_build_yaml_all_states() {
1140 for state in DocState::all_states() {
1141 let metadata = DocMetadata {
1142 number: 1,
1143 title: "Test".to_string(),
1144 author: "Author".to_string(),
1145 component: None,
1146 tags: Vec::new(),
1147 created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1148 updated: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1149 state,
1150 supersedes: None,
1151 superseded_by: None,
1152 version: "1.0".to_string(),
1153 };
1154
1155 let yaml = build_yaml_frontmatter(&metadata);
1156 assert!(yaml.contains(&format!("state: {}\n", state.as_str())));
1157 }
1158 }
1159
1160 #[test]
1161 fn test_build_yaml_frontmatter_escapes_quotes() {
1162 let metadata = DocMetadata {
1163 number: 1,
1164 title: "Test \"Title\" with Quotes".to_string(),
1165 author: "\"Jane Developer\"".to_string(),
1166 component: None,
1167 tags: Vec::new(),
1168 created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1169 updated: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1170 state: DocState::Draft,
1171 supersedes: None,
1172 superseded_by: None,
1173 version: "1.0".to_string(),
1174 };
1175
1176 let yaml = build_yaml_frontmatter(&metadata);
1177
1178 assert!(yaml.contains("title: \"Test \\\"Title\\\" with Quotes\"\n"));
1180 assert!(yaml.contains("author: \"\\\"Jane Developer\\\"\"\n"));
1181
1182 assert!(yaml.starts_with("---\n"));
1184 assert!(yaml.ends_with("---\n\n"));
1185 }
1186
1187 #[test]
1188 fn test_build_yaml_frontmatter_escapes_backslashes() {
1189 let metadata = DocMetadata {
1190 number: 1,
1191 title: "Path\\to\\file".to_string(),
1192 author: "Author\\Name".to_string(),
1193 component: None,
1194 tags: Vec::new(),
1195 created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1196 updated: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1197 state: DocState::Draft,
1198 supersedes: None,
1199 superseded_by: None,
1200 version: "1.0".to_string(),
1201 };
1202
1203 let yaml = build_yaml_frontmatter(&metadata);
1204
1205 assert!(yaml.contains("title: \"Path\\\\to\\\\file\"\n"));
1207 assert!(yaml.contains("author: \"Author\\\\Name\"\n"));
1208 }
1209
1210 #[test]
1211 fn test_update_yaml_field_exists() {
1212 let content = "---\ntitle: Old Title\nauthor: Someone\n---\nContent";
1213 let updated = DesignDoc::update_yaml_field(content, "title", "New Title").unwrap();
1214
1215 assert!(updated.contains("title: New Title"));
1216 assert!(!updated.contains("Old Title"));
1217 }
1218
1219 #[test]
1220 fn test_update_yaml_field_not_found() {
1221 let content = "---\ntitle: Title\nauthor: Someone\n---\nContent";
1222 let updated = DesignDoc::update_yaml_field(content, "nonexistent", "value").unwrap();
1223
1224 assert_eq!(updated, content);
1226 }
1227
1228 #[test]
1229 fn test_update_state_field() {
1230 let content = "---\ntitle: Test\nstate: Draft\nupdated: 2024-01-01\n---\nContent";
1231 let updated = DesignDoc::update_state(content, DocState::Final).unwrap();
1232
1233 assert!(updated.contains("state: Final"));
1234 assert!(updated.contains("updated:"));
1236 }
1237}
1238
1239#[cfg(test)]
1240mod file_operations_tests {
1241 use super::*;
1242 use std::fs;
1243 use tempfile::TempDir;
1244
1245 #[test]
1246 fn test_is_in_state_dir() {
1247 assert!(is_in_state_dir(Path::new("project/01-draft/doc.md")));
1249 assert!(is_in_state_dir(Path::new("project/06-final/doc.md")));
1250 assert!(is_in_state_dir(Path::new("/abs/path/02-under-review/doc.md")));
1251
1252 assert!(!is_in_state_dir(Path::new("project/doc.md")));
1254 assert!(!is_in_state_dir(Path::new("project/other-dir/doc.md")));
1255 }
1256
1257 #[test]
1258 fn test_is_in_state_dir_root_path() {
1259 assert!(!is_in_state_dir(Path::new("/")));
1260 assert!(!is_in_state_dir(Path::new("doc.md")));
1261 }
1262
1263 #[test]
1264 fn test_state_from_directory() {
1265 assert_eq!(
1266 state_from_directory(Path::new("project/01-draft/doc.md")),
1267 Some(DocState::Draft)
1268 );
1269 assert_eq!(
1270 state_from_directory(Path::new("project/06-final/doc.md")),
1271 Some(DocState::Final)
1272 );
1273 assert_eq!(state_from_directory(Path::new("project/doc.md")), None);
1274 }
1275
1276 #[test]
1277 fn test_move_to_project() {
1278 let temp = TempDir::new().unwrap();
1279 let project_dir = temp.path();
1280
1281 let subdir = project_dir.join("subdir");
1283 fs::create_dir(&subdir).unwrap();
1284 let file_path = subdir.join("test.md");
1285 fs::write(&file_path, "content").unwrap();
1286
1287 let new_path = move_to_project(&file_path, project_dir).unwrap();
1289
1290 assert_eq!(new_path, project_dir.join("test.md"));
1291 assert!(new_path.exists());
1292 assert!(!file_path.exists());
1293 }
1294
1295 #[test]
1296 fn test_move_to_state_dir() {
1297 let temp = TempDir::new().unwrap();
1298 let project_dir = temp.path();
1299
1300 let file_path = project_dir.join("test.md");
1302 fs::write(&file_path, "content").unwrap();
1303
1304 let new_path = move_to_state_dir(&file_path, DocState::Draft, project_dir).unwrap();
1306
1307 assert_eq!(new_path, project_dir.join("01-draft/test.md"));
1308 assert!(new_path.exists());
1309 assert!(!file_path.exists());
1310 }
1311
1312 #[test]
1313 fn test_move_to_state_dir_creates_directory() {
1314 let temp = TempDir::new().unwrap();
1315 let project_dir = temp.path();
1316
1317 let file_path = project_dir.join("test.md");
1318 fs::write(&file_path, "content").unwrap();
1319
1320 assert!(!project_dir.join("01-draft").exists());
1322
1323 move_to_state_dir(&file_path, DocState::Draft, project_dir).unwrap();
1325
1326 assert!(project_dir.join("01-draft").exists());
1327 }
1328
1329 #[test]
1330 fn test_add_number_prefix() {
1331 let temp = TempDir::new().unwrap();
1332 let file_path = temp.path().join("test.md");
1333 fs::write(&file_path, "content").unwrap();
1334
1335 let new_path = add_number_prefix(&file_path, 42).unwrap();
1336
1337 assert_eq!(new_path.file_name().unwrap(), "0042-test.md");
1338 assert!(new_path.exists());
1339 assert!(!file_path.exists());
1340 }
1341
1342 #[test]
1343 fn test_is_in_project_dir_valid() {
1344 let temp = TempDir::new().unwrap();
1345 let project_dir = temp.path();
1346 let file_path = project_dir.join("test.md");
1347 fs::write(&file_path, "content").unwrap();
1348
1349 let result = is_in_project_dir(&file_path, project_dir).unwrap();
1350 assert!(result);
1351 }
1352
1353 #[test]
1354 fn test_is_in_project_dir_outside() {
1355 let temp1 = TempDir::new().unwrap();
1356 let temp2 = TempDir::new().unwrap();
1357 let project_dir = temp1.path();
1358 let file_path = temp2.path().join("test.md");
1359 fs::write(&file_path, "content").unwrap();
1360
1361 let result = is_in_project_dir(&file_path, project_dir).unwrap();
1362 assert!(!result);
1363 }
1364
1365 #[test]
1366 fn test_add_missing_headers_no_frontmatter() {
1367 let temp = TempDir::new().unwrap();
1368 let file_path = temp.path().join("0042-test-doc.md");
1369 let content = "# Test Document\n\nSome content here.";
1370
1371 let (new_content, added_fields) = add_missing_headers(&file_path, content).unwrap();
1372
1373 assert!(new_content.starts_with("---\n"));
1374 assert!(new_content.contains("number: 42"));
1375 assert!(new_content.contains("title: \"Test Document\""));
1376 assert!(new_content.contains("state: Draft"));
1377 assert_eq!(added_fields.len(), 9);
1378 assert!(added_fields.contains(&"number".to_string()));
1379 }
1380
1381 #[test]
1382 fn test_add_missing_headers_with_valid_frontmatter() {
1383 let temp = TempDir::new().unwrap();
1384 let file_path = temp.path().join("0042-test-doc.md");
1385 let content = "---\nnumber: 100\ntitle: \"Existing Title\"\nauthor: \"Existing Author\"\ncreated: 2024-01-01\nupdated: 2024-01-02\nstate: Draft\nsupersedes: null\nsuperseded-by: null\n---\n\n# Test Document\n\nContent";
1386
1387 let (new_content, added_fields) = add_missing_headers(&file_path, content).unwrap();
1388
1389 assert!(new_content.contains("number: 100"));
1390 assert!(new_content.contains("title: \"Existing Title\""));
1391 assert_eq!(added_fields.len(), 0);
1392 }
1393
1394 #[test]
1395 fn test_add_missing_headers_with_partial_frontmatter() {
1396 let temp = TempDir::new().unwrap();
1397 let file_path = temp.path().join("0042-test-doc.md");
1398 let content = "---\nnumber: 0\ntitle: \"\"\nauthor: Unknown Author\ncreated: 2024-01-01\nupdated: 2024-01-02\nstate: Draft\nsupersedes: null\nsuperseded-by: null\n---\n\n# Test Document\n\nContent";
1399
1400 let (new_content, added_fields) = add_missing_headers(&file_path, content).unwrap();
1401
1402 assert!(new_content.contains("number: 42"));
1403 assert!(new_content.contains("title: \"Test Document\""));
1404 assert!(added_fields.contains(&"number".to_string()));
1405 assert!(added_fields.contains(&"title".to_string()));
1406 assert!(added_fields.contains(&"author".to_string()));
1407 }
1408
1409 #[test]
1410 fn test_add_missing_headers_with_broken_frontmatter() {
1411 let temp = TempDir::new().unwrap();
1412 let file_path = temp.path().join("0042-test-doc.md");
1413 let content =
1414 "---\nbroken yaml here\nno valid structure\n---\n\n# Test Document\n\nContent";
1415
1416 let (new_content, added_fields) = add_missing_headers(&file_path, content).unwrap();
1417
1418 assert!(new_content.starts_with("---\n"));
1419 assert!(new_content.contains("number: 42"));
1420 assert!(new_content.contains("title: \"Test Document\""));
1421 assert_eq!(added_fields.len(), 9);
1422 }
1423
1424 #[test]
1425 fn test_ensure_valid_headers_missing_frontmatter() {
1426 let temp = TempDir::new().unwrap();
1427 let file_path = temp.path().join("0042-test-doc.md");
1428 let content = "# Test Document\n\nContent without frontmatter.";
1429
1430 let result = ensure_valid_headers(&file_path, content).unwrap();
1431
1432 assert!(result.starts_with("---\n"));
1433 assert!(result.contains("number: 42"));
1434 }
1435
1436 #[test]
1437 fn test_ensure_valid_headers_with_placeholders() {
1438 let temp = TempDir::new().unwrap();
1439 let file_path = temp.path().join("0042-test-doc.md");
1440 let content = "---\nnumber: NNNN\ntitle: \"Test\"\nauthor: Unknown\ncreated: 2024-01-01\nupdated: 2024-01-02\nstate: Draft\nsupersedes: null\nsuperseded-by: null\n---\n\nContent";
1441
1442 let result = ensure_valid_headers(&file_path, content).unwrap();
1443
1444 assert!(result.contains("number: 42"));
1445 assert!(!result.contains("NNNN"));
1446 }
1447
1448 #[test]
1449 fn test_ensure_valid_headers_already_valid() {
1450 let temp = TempDir::new().unwrap();
1451 let file_path = temp.path().join("0042-test-doc.md");
1452 let content = "---\nnumber: 42\ntitle: \"Test\"\nauthor: \"Author\"\ncreated: 2024-01-01\nupdated: 2024-01-02\nstate: Draft\nsupersedes: null\nsuperseded-by: null\n---\n\nContent";
1453
1454 let result = ensure_valid_headers(&file_path, content).unwrap();
1455
1456 assert_eq!(result, content);
1457 }
1458
1459 #[test]
1460 fn test_sync_state_with_directory_matching() {
1461 let temp = TempDir::new().unwrap();
1462 let state_dir = temp.path().join("01-draft");
1463 fs::create_dir(&state_dir).unwrap();
1464 let file_path = state_dir.join("test.md");
1465 let content = "---\nnumber: 42\ntitle: \"Test\"\nauthor: \"Author\"\ncreated: 2024-01-01\nupdated: 2024-01-02\nstate: Draft\nsupersedes: null\nsuperseded-by: null\n---\n\nContent";
1466
1467 let result = sync_state_with_directory(&file_path, content).unwrap();
1468
1469 assert_eq!(result, content);
1470 }
1471
1472 #[test]
1473 fn test_sync_state_with_directory_mismatched() {
1474 let temp = TempDir::new().unwrap();
1475 let state_dir = temp.path().join("06-final");
1476 fs::create_dir(&state_dir).unwrap();
1477 let file_path = state_dir.join("test.md");
1478 let content = "---\nnumber: 42\ntitle: \"Test\"\nauthor: \"Author\"\ncreated: 2024-01-01\nupdated: 2024-01-02\nstate: Draft\nsupersedes: null\nsuperseded-by: null\n---\n\nContent";
1479
1480 let result = sync_state_with_directory(&file_path, content).unwrap();
1481
1482 assert!(result.contains("state: Final"));
1483 assert!(!result.contains("state: Draft"));
1484 }
1485
1486 #[test]
1487 fn test_sync_state_with_directory_error() {
1488 let temp = TempDir::new().unwrap();
1489 let file_path = temp.path().join("test.md");
1490 let content = "---\nnumber: 42\ntitle: \"Test\"\nauthor: \"Author\"\ncreated: 2024-01-01\nupdated: 2024-01-02\nstate: Draft\nsupersedes: null\nsuperseded-by: null\n---\n\nContent";
1491
1492 let result = sync_state_with_directory(&file_path, content);
1493
1494 assert!(result.is_err());
1495 match result {
1496 Err(DocError::InvalidFormat(msg)) => {
1497 assert!(msg.contains("not in a state directory"));
1498 }
1499 _ => panic!("Expected InvalidFormat error"),
1500 }
1501 }
1502}
1503
1504#[cfg(test)]
1505mod property_tests {
1506 use super::*;
1507 use chrono::NaiveDate;
1508 use proptest::prelude::*;
1509
1510 proptest! {
1511 #[test]
1512 fn state_as_str_from_str_round_trip(state in prop::sample::select(DocState::all_states())) {
1513 let str_repr = state.as_str();
1514 prop_assert_eq!(DocState::from_str_flexible(str_repr), Some(state));
1515 }
1516
1517 #[test]
1518 fn state_directory_from_directory_round_trip(state in prop::sample::select(DocState::all_states())) {
1519 let dir_repr = state.directory();
1520 prop_assert_eq!(DocState::from_directory(dir_repr), Some(state));
1521 }
1522
1523 #[test]
1524 fn extract_number_is_consistent(num in 0u32..10000) {
1525 let filename = format!("{:04}-test.md", num);
1526 prop_assert_eq!(extract_number_from_filename(&filename), num);
1527 }
1528
1529 #[test]
1530 fn has_number_prefix_consistency(num in 0u32..10000, title in "[a-z]+") {
1531 let filename = format!("{:04}-{}.md", num, title);
1532 prop_assert!(has_number_prefix(&filename));
1533 }
1534
1535 #[test]
1536 fn yaml_frontmatter_starts_and_ends_correctly(
1537 num in 1u32..10000,
1538 state in prop::sample::select(DocState::all_states())
1539 ) {
1540 let metadata = DocMetadata {
1541 number: num,
1542 title: "Test".to_string(),
1543 author: "Author".to_string(),
1544 component: None,
1545 tags: Vec::new(),
1546 created: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1547 updated: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1548 state,
1549 supersedes: None,
1550 superseded_by: None,
1551 version: "1.0".to_string(),
1552 };
1553
1554 let yaml = build_yaml_frontmatter(&metadata);
1555 prop_assert!(yaml.starts_with("---\n"));
1556 prop_assert!(yaml.ends_with("---\n\n"));
1557 }
1558 }
1559
1560 mod version_tests {
1561 use super::*;
1562
1563 #[test]
1564 fn test_parse_version_valid() {
1565 assert_eq!(parse_version("1.0"), Ok((1, 0)));
1566 assert_eq!(parse_version("2.5"), Ok((2, 5)));
1567 assert_eq!(parse_version("10.42"), Ok((10, 42)));
1568 }
1569
1570 #[test]
1571 fn test_parse_version_invalid() {
1572 assert!(parse_version("1").is_err());
1573 assert!(parse_version("1.0.0").is_err());
1574 assert!(parse_version("1.a").is_err());
1575 assert!(parse_version("a.1").is_err());
1576 }
1577
1578 #[test]
1579 fn test_increment_minor_version() {
1580 assert_eq!(increment_minor_version("1.0"), Ok("1.1".to_string()));
1581 assert_eq!(increment_minor_version("2.5"), Ok("2.6".to_string()));
1582 assert_eq!(increment_minor_version("1.9"), Ok("1.10".to_string()));
1583 }
1584
1585 #[test]
1586 fn test_is_version_valid_upgrade() {
1587 assert_eq!(is_version_valid_upgrade("1.0", "1.1"), Ok(true));
1589 assert_eq!(is_version_valid_upgrade("1.0", "2.0"), Ok(true));
1590 assert_eq!(is_version_valid_upgrade("1.5", "1.5"), Ok(true)); assert_eq!(is_version_valid_upgrade("2.0", "1.9"), Ok(false));
1594 assert_eq!(is_version_valid_upgrade("1.5", "1.4"), Ok(false));
1595 }
1596 }
1597}