claude_agent/common/
content_source.rs

1//! Content source abstraction for lazy loading.
2//!
3//! `ContentSource` represents where content can be loaded from,
4//! enabling progressive disclosure where metadata is always available
5//! but full content is loaded on-demand.
6
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11/// Source location for loading content on-demand.
12///
13/// This enables the progressive disclosure pattern where indices contain
14/// minimal metadata (name, description) while full content is loaded
15/// only when needed.
16#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(tag = "type", rename_all = "snake_case")]
18pub enum ContentSource {
19    /// File system path
20    File {
21        /// Path to the content file
22        path: PathBuf,
23    },
24
25    /// In-memory content (already loaded or code-defined)
26    InMemory {
27        /// The actual content
28        content: String,
29    },
30
31    /// Database storage (for server environments)
32    Database {
33        /// Content ID in database
34        id: String,
35    },
36
37    /// HTTP endpoint (for remote content)
38    Http {
39        /// URL to fetch content from
40        url: String,
41    },
42}
43
44impl ContentSource {
45    /// Create a file-based content source.
46    pub fn file(path: impl Into<PathBuf>) -> Self {
47        Self::File { path: path.into() }
48    }
49
50    /// Create an in-memory content source.
51    pub fn in_memory(content: impl Into<String>) -> Self {
52        Self::InMemory {
53            content: content.into(),
54        }
55    }
56
57    /// Create a database content source.
58    pub fn database(id: impl Into<String>) -> Self {
59        Self::Database { id: id.into() }
60    }
61
62    /// Create an HTTP content source.
63    pub fn http(url: impl Into<String>) -> Self {
64        Self::Http { url: url.into() }
65    }
66
67    /// Load the content from this source.
68    ///
69    /// This is the core lazy-loading mechanism. Content is only fetched
70    /// when this method is called, not when the index is created.
71    pub async fn load(&self) -> crate::Result<String> {
72        match self {
73            Self::File { path } => tokio::fs::read_to_string(path).await.map_err(|e| {
74                crate::Error::Config(format!("Failed to load content from {:?}: {}", path, e))
75            }),
76            Self::InMemory { content } => Ok(content.clone()),
77            Self::Database { id } => Err(crate::Error::Config(format!(
78                "Database content source not implemented: {}",
79                id
80            ))),
81            Self::Http { url } => Err(crate::Error::Config(format!(
82                "HTTP content source not implemented: {}",
83                url
84            ))),
85        }
86    }
87
88    /// Check if this is an in-memory source.
89    pub fn is_in_memory(&self) -> bool {
90        matches!(self, Self::InMemory { .. })
91    }
92
93    /// Check if this is a file source.
94    pub fn is_file(&self) -> bool {
95        matches!(self, Self::File { .. })
96    }
97
98    /// Get the file path if this is a file source.
99    pub fn as_file_path(&self) -> Option<&PathBuf> {
100        match self {
101            Self::File { path } => Some(path),
102            _ => None,
103        }
104    }
105
106    /// Get the parent directory if this is a file source.
107    pub fn base_dir(&self) -> Option<PathBuf> {
108        match self {
109            Self::File { path } => path.parent().map(|p| p.to_path_buf()),
110            _ => None,
111        }
112    }
113}
114
115impl Default for ContentSource {
116    fn default() -> Self {
117        Self::InMemory {
118            content: String::new(),
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_content_source_constructors() {
129        let file = ContentSource::file("/path/to/file.md");
130        assert!(file.is_file());
131        assert_eq!(
132            file.as_file_path(),
133            Some(&PathBuf::from("/path/to/file.md"))
134        );
135
136        let memory = ContentSource::in_memory("content here");
137        assert!(memory.is_in_memory());
138
139        let db = ContentSource::database("skill-123");
140        assert!(matches!(db, ContentSource::Database { id } if id == "skill-123"));
141
142        let http = ContentSource::http("https://example.com/skill.md");
143        assert!(
144            matches!(http, ContentSource::Http { url } if url == "https://example.com/skill.md")
145        );
146    }
147
148    #[test]
149    fn test_base_dir() {
150        let file = ContentSource::file("/home/user/.claude/skills/commit/SKILL.md");
151        assert_eq!(
152            file.base_dir(),
153            Some(PathBuf::from("/home/user/.claude/skills/commit"))
154        );
155
156        let memory = ContentSource::in_memory("content");
157        assert_eq!(memory.base_dir(), None);
158    }
159
160    #[tokio::test]
161    async fn test_load_in_memory() {
162        let source = ContentSource::in_memory("test content");
163        let content = source.load().await.unwrap();
164        assert_eq!(content, "test content");
165    }
166
167    #[tokio::test]
168    async fn test_load_file() {
169        use std::io::Write;
170        use tempfile::NamedTempFile;
171
172        let mut file = NamedTempFile::new().unwrap();
173        writeln!(file, "file content").unwrap();
174
175        let source = ContentSource::file(file.path());
176        let content = source.load().await.unwrap();
177        assert!(content.contains("file content"));
178    }
179
180    #[tokio::test]
181    async fn test_load_file_not_found() {
182        let source = ContentSource::file("/nonexistent/path/file.md");
183        let result = source.load().await;
184        assert!(result.is_err());
185    }
186
187    #[test]
188    fn test_serde_roundtrip() {
189        let sources = vec![
190            ContentSource::file("/path/to/file.md"),
191            ContentSource::in_memory("content"),
192            ContentSource::database("id-123"),
193            ContentSource::http("https://example.com"),
194        ];
195
196        for source in sources {
197            let json = serde_json::to_string(&source).unwrap();
198            let parsed: ContentSource = serde_json::from_str(&json).unwrap();
199            assert_eq!(source, parsed);
200        }
201    }
202}