Skip to main content

agent_first_http/sdk/profile/
mod.rs

1//! Local profile lifecycle: list / info / lock-status / downloads / delete / prune.
2//!
3//! Operates on disk only; never reaches over the network. Used by both
4//! the SDK consumer and the `afhttp profile` CLI subcommand.
5
6pub 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/// Top-level profile inventory entry from `afhttp profile list`.
20#[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"); // sorted
280        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        // Hold the lock for the duration of the assertion.
347        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        // Set mtime far in the past.
357        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}