claude_agent/common/
content_source.rs1use std::path::PathBuf;
8use std::sync::OnceLock;
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12
13static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
15
16const 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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
34#[serde(tag = "type", rename_all = "snake_case")]
35pub enum ContentSource {
36 File {
38 path: PathBuf,
40 },
41
42 InMemory {
44 content: String,
46 },
47
48 Http {
50 url: String,
52 },
53}
54
55impl ContentSource {
56 pub fn file(path: impl Into<PathBuf>) -> Self {
58 Self::File { path: path.into() }
59 }
60
61 pub fn in_memory(content: impl Into<String>) -> Self {
63 Self::InMemory {
64 content: content.into(),
65 }
66 }
67
68 pub fn http(url: impl Into<String>) -> Self {
70 Self::Http { url: url.into() }
71 }
72
73 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 pub fn is_in_memory(&self) -> bool {
107 matches!(self, Self::InMemory { .. })
108 }
109
110 pub fn is_file(&self) -> bool {
112 matches!(self, Self::File { .. })
113 }
114
115 pub fn as_file_path(&self) -> Option<&PathBuf> {
117 match self {
118 Self::File { path } => Some(path),
119 _ => None,
120 }
121 }
122
123 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}