Skip to main content

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