agent_first_http/sdk/profile/
mod.rs1pub mod cookie_jar;
7pub mod info;
8pub mod lock;
9pub mod meta;
10pub mod paths;
11
12use std::path::{Path, PathBuf};
13use std::time::{Duration, SystemTime};
14
15use serde::{Deserialize, Serialize};
16
17use crate::shared::error::{Error, ErrorCode};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ProfileEntry {
22 pub name: String,
23 pub path: PathBuf,
24 pub size_bytes: u64,
25 pub metadata_present: bool,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub last_used_at_rfc3339: Option<String>,
28 pub locked: bool,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct DownloadEntry {
33 pub filename: String,
34 pub path: PathBuf,
35 pub size_bytes: u64,
36 pub state: String,
37}
38
39fn root_or_default(root: Option<&Path>) -> PathBuf {
40 root.map(Path::to_path_buf)
41 .unwrap_or_else(paths::default_root)
42}
43
44pub fn list(profile_root: Option<&Path>) -> Result<Vec<ProfileEntry>, Error> {
45 let root = root_or_default(profile_root);
46 if !root.exists() {
47 return Ok(Vec::new());
48 }
49 let read = std::fs::read_dir(&root).map_err(|e| {
50 Error::new(
51 ErrorCode::ProfileRootUnavailable,
52 format!("read_dir({}): {e}", root.display()),
53 )
54 })?;
55 let mut out = Vec::new();
56 for entry in read.flatten() {
57 let path = entry.path();
58 if !path.is_dir() {
59 continue;
60 }
61 let name = match path.file_name().and_then(|s| s.to_str()) {
62 Some(n) => n.to_string(),
63 None => continue,
64 };
65 out.push(info_at(&path, &name)?);
66 }
67 out.sort_by(|a, b| a.name.cmp(&b.name));
68 Ok(out)
69}
70
71pub fn info(name: &str, profile_root: Option<&Path>) -> Result<ProfileEntry, Error> {
72 paths::validate_name(name)?;
73 let root = root_or_default(profile_root);
74 let dir = root.join(name);
75 if !dir.exists() {
76 return Err(Error::new(
77 ErrorCode::ProfileNotFound,
78 format!("profile {name:?} not found at {}", dir.display()),
79 ));
80 }
81 info_at(&dir, name)
82}
83
84fn info_at(dir: &Path, name: &str) -> Result<ProfileEntry, Error> {
85 let meta_path = dir.join("afhttp-profile.json");
86 let (metadata_present, last_used_at) = if meta_path.exists() {
87 match std::fs::read_to_string(&meta_path) {
88 Ok(s) => match serde_json::from_str::<meta::ProfileMeta>(&s) {
89 Ok(m) => (true, Some(m.last_used_at_rfc3339)),
90 Err(_) => (false, None),
91 },
92 Err(_) => (false, None),
93 }
94 } else {
95 (false, None)
96 };
97 let size_bytes = dir_size(dir).unwrap_or(0);
98 let locked = lock::probe(dir);
99 Ok(ProfileEntry {
100 name: name.to_string(),
101 path: dir.to_path_buf(),
102 size_bytes,
103 metadata_present,
104 last_used_at_rfc3339: last_used_at,
105 locked,
106 })
107}
108
109pub fn lock_status(name: &str, profile_root: Option<&Path>) -> Result<lock::LockStatus, Error> {
110 paths::validate_name(name)?;
111 let root = root_or_default(profile_root);
112 let dir = root.join(name);
113 if !dir.exists() {
114 return Err(Error::new(
115 ErrorCode::ProfileNotFound,
116 format!("profile {name:?} not found"),
117 ));
118 }
119 Ok(lock::status(&dir))
120}
121
122pub fn downloads(name: &str, profile_root: Option<&Path>) -> Result<Vec<DownloadEntry>, Error> {
123 let entry = info(name, profile_root)?;
124 downloads_at(&entry.path)
125}
126
127fn downloads_at(profile_dir: &Path) -> Result<Vec<DownloadEntry>, Error> {
128 let dir = profile_dir.join("downloads");
129 if !dir.exists() {
130 return Ok(Vec::new());
131 }
132 let read = std::fs::read_dir(&dir).map_err(|e| {
133 Error::new(
134 ErrorCode::IoError,
135 format!("read_dir({}): {e}", dir.display()),
136 )
137 })?;
138 let mut out = Vec::new();
139 for entry in read.flatten() {
140 let path = entry.path();
141 let Ok(meta) = entry.metadata() else {
142 continue;
143 };
144 if !meta.is_file() {
145 continue;
146 }
147 let Some(filename) = path.file_name().and_then(|name| name.to_str()) else {
148 continue;
149 };
150 let filename = filename.to_string();
151 let state = if path.extension().and_then(|ext| ext.to_str()) == Some("crdownload") {
152 "in_progress"
153 } else {
154 "completed"
155 };
156 let path = path.canonicalize().unwrap_or(path);
157 out.push(DownloadEntry {
158 filename,
159 path,
160 size_bytes: meta.len(),
161 state: state.to_string(),
162 });
163 }
164 out.sort_by(|a, b| a.filename.cmp(&b.filename));
165 Ok(out)
166}
167
168pub fn delete(name: &str, confirm: &str, profile_root: Option<&Path>) -> Result<(), Error> {
169 paths::validate_name(name)?;
170 if name != confirm {
171 return Err(Error::new(
172 ErrorCode::InvalidArgument,
173 format!("profile delete: --confirm must match name (got {confirm:?})"),
174 ));
175 }
176 let root = root_or_default(profile_root);
177 let dir = root.join(name);
178 if !dir.exists() {
179 return Err(Error::new(
180 ErrorCode::ProfileNotFound,
181 format!("profile {name:?} not found"),
182 ));
183 }
184 if lock::probe(&dir) {
185 return Err(Error::new(
186 ErrorCode::ProfileDeleteLocked,
187 format!("profile {name:?} is locked; cannot delete"),
188 ));
189 }
190 std::fs::remove_dir_all(&dir).map_err(|e| {
191 Error::new(
192 ErrorCode::IoError,
193 format!("remove_dir_all({}): {e}", dir.display()),
194 )
195 })
196}
197
198pub fn prune(
199 older_than: Duration,
200 dry_run: bool,
201 profile_root: Option<&Path>,
202) -> Result<Vec<ProfileEntry>, Error> {
203 let entries = list(profile_root)?;
204 let now = SystemTime::now();
205 let mut removed = Vec::new();
206 for entry in entries {
207 if entry.locked {
208 continue;
209 }
210 let too_old = match std::fs::metadata(&entry.path) {
211 Ok(m) => match m.modified() {
212 Ok(t) => match now.duration_since(t) {
213 Ok(age) => age >= older_than,
214 Err(_) => false,
215 },
216 Err(_) => false,
217 },
218 Err(_) => false,
219 };
220 if !too_old {
221 continue;
222 }
223 if !dry_run {
224 std::fs::remove_dir_all(&entry.path).map_err(|e| {
225 Error::new(
226 ErrorCode::IoError,
227 format!("remove_dir_all({}): {e}", entry.path.display()),
228 )
229 })?;
230 }
231 removed.push(entry);
232 }
233 Ok(removed)
234}
235
236fn dir_size(dir: &Path) -> std::io::Result<u64> {
237 let mut total: u64 = 0;
238 let mut stack = vec![dir.to_path_buf()];
239 while let Some(p) = stack.pop() {
240 for entry in std::fs::read_dir(&p)?.flatten() {
241 let path = entry.path();
242 let md = match entry.metadata() {
243 Ok(m) => m,
244 Err(_) => continue,
245 };
246 if md.is_file() {
247 total = total.saturating_add(md.len());
248 } else if md.is_dir() {
249 stack.push(path);
250 }
251 }
252 }
253 Ok(total)
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 fn touch_profile(root: &Path, name: &str) -> PathBuf {
261 let dir = root.join(name);
262 std::fs::create_dir_all(&dir).unwrap();
263 let meta = meta::ProfileMeta::new(name);
264 std::fs::write(
265 dir.join("afhttp-profile.json"),
266 serde_json::to_string(&meta).unwrap(),
267 )
268 .unwrap();
269 dir
270 }
271
272 #[test]
273 fn lists_existing_profiles() {
274 let tmp = tempfile::tempdir().unwrap();
275 touch_profile(tmp.path(), "work");
276 touch_profile(tmp.path(), "alpha");
277 let entries = list(Some(tmp.path())).unwrap();
278 assert_eq!(entries.len(), 2);
279 assert_eq!(entries[0].name, "alpha"); assert_eq!(entries[1].name, "work");
281 assert!(entries[0].metadata_present);
282 }
283
284 #[test]
285 fn info_returns_profile_details() {
286 let tmp = tempfile::tempdir().unwrap();
287 touch_profile(tmp.path(), "work");
288 let entry = info("work", Some(tmp.path())).unwrap();
289 assert_eq!(entry.name, "work");
290 assert!(entry.metadata_present);
291 assert!(!entry.locked);
292 }
293
294 #[test]
295 fn downloads_lists_profile_download_artifacts() {
296 let tmp = tempfile::tempdir().unwrap();
297 let profile = touch_profile(tmp.path(), "work");
298 let download_dir = profile.join("downloads");
299 std::fs::create_dir_all(&download_dir).unwrap();
300 std::fs::write(download_dir.join("report.csv"), "abc").unwrap();
301 std::fs::write(download_dir.join("pending.bin.crdownload"), "partial").unwrap();
302
303 let entries = downloads("work", Some(tmp.path())).unwrap();
304 assert_eq!(entries.len(), 2);
305 assert_eq!(entries[0].filename, "pending.bin.crdownload");
306 assert_eq!(entries[0].state, "in_progress");
307 assert_eq!(entries[1].filename, "report.csv");
308 assert_eq!(entries[1].size_bytes, 3);
309 assert_eq!(entries[1].state, "completed");
310 }
311
312 #[test]
313 fn info_missing_profile_returns_not_found() {
314 let tmp = tempfile::tempdir().unwrap();
315 let err = info("nope", Some(tmp.path())).err().unwrap();
316 assert_eq!(err.error_code, ErrorCode::ProfileNotFound);
317 }
318
319 #[test]
320 fn delete_requires_matching_confirm() {
321 let tmp = tempfile::tempdir().unwrap();
322 touch_profile(tmp.path(), "work");
323 let err = delete("work", "different", Some(tmp.path())).err().unwrap();
324 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
325 }
326
327 #[test]
328 fn delete_removes_profile() {
329 let tmp = tempfile::tempdir().unwrap();
330 let dir = touch_profile(tmp.path(), "work");
331 delete("work", "work", Some(tmp.path())).unwrap();
332 assert!(!dir.exists());
333 }
334
335 #[test]
336 fn delete_missing_returns_not_found() {
337 let tmp = tempfile::tempdir().unwrap();
338 let err = delete("nope", "nope", Some(tmp.path())).err().unwrap();
339 assert_eq!(err.error_code, ErrorCode::ProfileNotFound);
340 }
341
342 #[test]
343 fn delete_locked_refuses() {
344 let tmp = tempfile::tempdir().unwrap();
345 let dir = touch_profile(tmp.path(), "work");
346 let _g = lock::Guard::acquire(&dir).unwrap();
348 let err = delete("work", "work", Some(tmp.path())).err().unwrap();
349 assert_eq!(err.error_code, ErrorCode::ProfileDeleteLocked);
350 }
351
352 #[test]
353 fn prune_dry_run_does_not_delete() {
354 let tmp = tempfile::tempdir().unwrap();
355 let dir = touch_profile(tmp.path(), "work");
356 let two_hours = SystemTime::now() - Duration::from_secs(7200);
358 filetime::set_file_mtime(&dir, filetime::FileTime::from_system_time(two_hours)).ok();
359 let removed = prune(Duration::from_secs(3600), true, Some(tmp.path())).unwrap();
360 assert_eq!(removed.len(), 1);
361 assert!(dir.exists());
362 }
363
364 #[test]
365 fn prune_removes_old_profiles() {
366 let tmp = tempfile::tempdir().unwrap();
367 let dir = touch_profile(tmp.path(), "work");
368 let two_hours = SystemTime::now() - Duration::from_secs(7200);
369 filetime::set_file_mtime(&dir, filetime::FileTime::from_system_time(two_hours)).ok();
370 let removed = prune(Duration::from_secs(3600), false, Some(tmp.path())).unwrap();
371 assert_eq!(removed.len(), 1);
372 assert!(!dir.exists());
373 }
374}