sara_core/model/
metadata.rs

1//! Metadata structures for items and source tracking.
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::path::PathBuf;
6
7/// Tracks the file origin of an item for error reporting.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct SourceLocation {
10    /// Repository path (absolute).
11    pub repository: PathBuf,
12
13    /// Relative path within repository.
14    pub file_path: PathBuf,
15
16    /// Line number where item definition starts (1-indexed).
17    pub line: usize,
18
19    /// Optional Git commit/branch if reading from history.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub git_ref: Option<String>,
22}
23
24impl SourceLocation {
25    /// Creates a new SourceLocation.
26    pub fn new(repository: impl Into<PathBuf>, file_path: impl Into<PathBuf>, line: usize) -> Self {
27        Self {
28            repository: repository.into(),
29            file_path: file_path.into(),
30            line,
31            git_ref: None,
32        }
33    }
34
35    /// Creates a new SourceLocation with a Git reference.
36    pub fn with_git_ref(
37        repository: impl Into<PathBuf>,
38        file_path: impl Into<PathBuf>,
39        line: usize,
40        git_ref: impl Into<String>,
41    ) -> Self {
42        Self {
43            repository: repository.into(),
44            file_path: file_path.into(),
45            line,
46            git_ref: Some(git_ref.into()),
47        }
48    }
49
50    /// Returns the full path to the file.
51    pub fn full_path(&self) -> PathBuf {
52        self.repository.join(&self.file_path)
53    }
54
55    /// Format as "path/to/file.md:42".
56    pub fn display(&self) -> String {
57        format!("{}:{}", self.file_path.display(), self.line)
58    }
59
60    /// Format with repository prefix.
61    pub fn display_full(&self) -> String {
62        if let Some(ref git_ref) = self.git_ref {
63            format!(
64                "{}:{}:{} (at {})",
65                self.repository.display(),
66                self.file_path.display(),
67                self.line,
68                git_ref
69            )
70        } else {
71            format!(
72                "{}:{}:{}",
73                self.repository.display(),
74                self.file_path.display(),
75                self.line
76            )
77        }
78    }
79}
80
81impl fmt::Display for SourceLocation {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        write!(f, "{}", self.display())
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn test_source_location_display() {
93        let loc = SourceLocation::new("/repo", "docs/SOL-001.md", 5);
94        assert_eq!(loc.display(), "docs/SOL-001.md:5");
95    }
96
97    #[test]
98    fn test_source_location_full_path() {
99        let loc = SourceLocation::new("/repo", "docs/SOL-001.md", 5);
100        assert_eq!(loc.full_path(), PathBuf::from("/repo/docs/SOL-001.md"));
101    }
102
103    #[test]
104    fn test_source_location_with_git_ref() {
105        let loc = SourceLocation::with_git_ref("/repo", "docs/SOL-001.md", 5, "main");
106        assert_eq!(loc.git_ref, Some("main".to_string()));
107    }
108}