Skip to main content

design/
doc.rs

1//! Design document types and parsing
2
3use 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/// Document state - 12 states following the expanded lifecycle
25#[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,     // Document removed to dustbin
38    Overwritten, // Document replaced via oxd replace
39}
40
41impl DocState {
42    /// Get the display name for this state
43    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    /// Get the directory name for this state
61    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            // These states don't have standard directories
74            // They're in .dustbin with subdirectories
75            DocState::Removed => ".dustbin",
76            DocState::Overwritten => ".dustbin/overwritten",
77        }
78    }
79
80    /// Check if this state should be in the dustbin
81    pub fn is_in_dustbin(&self) -> bool {
82        matches!(self, DocState::Removed | DocState::Overwritten)
83    }
84
85    /// Parse from string, handling various formats (hyphens, spaces, case)
86    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    /// Get DocState from directory name
107    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    /// Get all valid states
126    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    /// Get all valid state names for display
144    pub fn all_state_names() -> Vec<&'static str> {
145        Self::all_states().iter().map(|s| s.as_str()).collect()
146    }
147
148    /// Get a description of what this state means
149    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
178/// Custom deserializer for version field with major.minor validation
179fn 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    // Validate format: major.minor (e.g., "1.0", "2.3")
187    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    // Validate both parts are numeric
197    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
210/// Parse a version string into (major, minor) components
211pub 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
226/// Increment the minor version of a version string
227pub fn increment_minor_version(version: &str) -> Result<String, String> {
228    let (major, minor) = parse_version(version)?;
229    Ok(format!("{}.{}", major, minor + 1))
230}
231
232/// Compare two versions, returns true if new_version >= old_version
233pub 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/// Metadata from the YAML frontmatter
241#[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/// A complete design document
265#[derive(Debug, Clone)]
266pub struct DesignDoc {
267    pub metadata: DocMetadata,
268    pub content: String,
269    pub path: PathBuf,
270}
271
272impl DesignDoc {
273    /// Parse a design document from markdown content
274    pub fn parse(content: &str, path: PathBuf) -> Result<Self, DocError> {
275        // Look for YAML frontmatter between --- markers
276        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        // Parse YAML frontmatter
286        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    /// Get the document filename based on number and state
293    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    /// Update a specific field in the YAML frontmatter
308    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    /// Update the state and updated date in one operation
318    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
328/// Escape a string for use in YAML double-quoted strings
329fn escape_yaml_string(s: &str) -> String {
330    s.replace('\\', "\\\\") // Escape backslashes first
331        .replace('"', "\\\"") // Then escape double quotes
332}
333
334/// Build complete YAML frontmatter from metadata
335pub fn build_yaml_frontmatter(metadata: &DocMetadata) -> String {
336    let mut yaml = String::from("---\n");
337
338    // 1-3: number, title, author
339    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    // 4: component (only if Some)
344    if let Some(component) = &metadata.component {
345        yaml.push_str(&format!("component: {}\n", component));
346    }
347
348    // 5: tags (only if non-empty) - YAML flow sequence
349    if !metadata.tags.is_empty() {
350        yaml.push_str(&format!("tags: [{}]\n", metadata.tags.join(", ")));
351    }
352
353    // 6-8: created, updated, state
354    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    // 9-10: supersedes, superseded-by
359    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    // 11: version (always present)
372    yaml.push_str(&format!("version: {}\n", metadata.version));
373
374    yaml.push_str("---\n\n");
375    yaml
376}
377
378/// Extract title from content (first # heading) or filename
379pub fn extract_title_from_content(content: &str, filename: &str) -> String {
380    // Look for first # heading
381    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    // Infer from filename: 0001-my-document.md -> "My Document"
389    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
410/// Extract number from filename (with 4-digit padding)
411pub 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
421/// Add or complete YAML frontmatter headers
422pub 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    // Extract metadata
435    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    // Check if document has frontmatter
444    if content.trim_start().starts_with("---") {
445        // Try to parse existing frontmatter
446        match DesignDoc::parse(content, path.to_path_buf()) {
447            Ok(doc) => {
448                // Merge with discovered metadata - only update empty/default fields
449                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                // Strip old frontmatter and add complete new one
465                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                // Partial/broken frontmatter, build from scratch
473                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                // Strip old frontmatter and add new
502                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        // No frontmatter, add it
510        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
544// ============================================================================
545// Task 4.1: Number Assignment Functions
546// ============================================================================
547
548/// Check if filename has a number prefix (e.g., 0001-, 0042-)
549pub fn has_number_prefix(filename: &str) -> bool {
550    let re = Regex::new(r"^\d{4}-").unwrap();
551    re.is_match(filename)
552}
553
554/// Rename file to include number prefix
555pub 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
569// ============================================================================
570// Task 4.2: Directory Placement Functions
571// ============================================================================
572
573/// Check if a path is within the project directory
574pub 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
581/// Check if a path is in one of the state directories
582pub 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
591/// Get the state from the file's current directory
592pub 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
600/// Move file to project directory
601pub 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
612/// Move file to a state directory
613pub 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
631// ============================================================================
632// Task 4.3: Header Processing Functions
633// ============================================================================
634
635/// Check if content has YAML frontmatter
636pub fn has_frontmatter(content: &str) -> bool {
637    content.trim_start().starts_with("---\n")
638}
639
640/// Check if frontmatter has placeholder values
641pub 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
648/// Ensure document has complete, valid headers
649pub 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
658// ============================================================================
659// Task 4.4: State Synchronization Functions
660// ============================================================================
661
662/// Sync document state with its directory location
663pub fn sync_state_with_directory(path: &Path, content: &str) -> Result<String, DocError> {
664    // Get state from directory
665    let dir_state = state_from_directory(path)
666        .ok_or_else(|| DocError::InvalidFormat("Document not in a state directory".to_string()))?;
667
668    // Parse document to get current state
669    let doc = DesignDoc::parse(content, path.to_path_buf())?;
670
671    // If states don't match, update the content
672    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        // Legacy directory names
775        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            // as_str -> from_str_flexible
852            let str_repr = state.as_str();
853            assert_eq!(DocState::from_str_flexible(str_repr), Some(state));
854
855            // directory -> from_directory
856            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        // This creates empty strings from double dashes, but still produces a title
1035        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        // Quotes should be escaped with backslashes
1179        assert!(yaml.contains("title: \"Test \\\"Title\\\" with Quotes\"\n"));
1180        assert!(yaml.contains("author: \"\\\"Jane Developer\\\"\"\n"));
1181
1182        // Verify the YAML can be parsed
1183        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        // Backslashes should be escaped
1206        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        // Should not modify if field doesn't exist
1225        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        // Updated date should change (we can't test exact date but can verify it changed)
1235        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        // Paths in state directories
1248        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        // Paths not in state directories
1253        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        // Create a file in a subdirectory
1282        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        // Move to project root
1288        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        // Create a file in project root
1301        let file_path = project_dir.join("test.md");
1302        fs::write(&file_path, "content").unwrap();
1303
1304        // Move to Draft state directory
1305        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        // State directory doesn't exist yet
1321        assert!(!project_dir.join("01-draft").exists());
1322
1323        // Should create it
1324        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            // Valid upgrades
1588            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)); // Equal is valid
1591
1592            // Invalid downgrades
1593            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}