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 backend: String,
23    pub name: String,
24    pub path: PathBuf,
25    pub size_bytes: u64,
26    pub metadata_present: bool,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub last_used_at_rfc3339: Option<String>,
29    pub locked: bool,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DownloadEntry {
34    pub filename: String,
35    pub path: PathBuf,
36    pub size_bytes: u64,
37    pub state: String,
38}
39
40fn root_or_default(root: Option<&Path>) -> PathBuf {
41    root.map(Path::to_path_buf)
42        .unwrap_or_else(paths::default_root)
43}
44
45pub fn list(profile_root: Option<&Path>) -> Result<Vec<ProfileEntry>, Error> {
46    let root = root_or_default(profile_root);
47    if !root.exists() {
48        return Ok(Vec::new());
49    }
50    let mut out = Vec::new();
51    for backend in paths::KNOWN_BACKENDS {
52        let backend_dir = root.join(backend);
53        if !backend_dir.is_dir() {
54            continue;
55        }
56        let read = std::fs::read_dir(&backend_dir).map_err(|e| {
57            Error::new(
58                ErrorCode::ProfileRootUnavailable,
59                format!("read_dir({}): {e}", backend_dir.display()),
60            )
61        })?;
62        for entry in read.flatten() {
63            let path = entry.path();
64            if !path.is_dir() {
65                continue;
66            }
67            let name = match path.file_name().and_then(|s| s.to_str()) {
68                Some(n) => n.to_string(),
69                None => continue,
70            };
71            out.push(info_at(&path, backend, &name)?);
72        }
73    }
74    out.sort_by(|a, b| a.backend.cmp(&b.backend).then_with(|| a.name.cmp(&b.name)));
75    Ok(out)
76}
77
78pub fn info(
79    name: &str,
80    backend: Option<&str>,
81    profile_root: Option<&Path>,
82) -> Result<ProfileEntry, Error> {
83    paths::validate_name(name)?;
84    let root = root_or_default(profile_root);
85    let (backend, dir) = resolve_backend_scoped_profile(&root, name, backend)?;
86    info_at(&dir, &backend, name)
87}
88
89fn info_at(dir: &Path, backend: &str, name: &str) -> Result<ProfileEntry, Error> {
90    let meta_path = dir.join("afhttp-profile.json");
91    let (metadata_present, last_used_at) = if meta_path.exists() {
92        let m = read_profile_meta(&meta_path)?;
93        validate_profile_meta(&m, backend, name, dir)?;
94        (true, Some(m.last_used_at_rfc3339))
95    } else {
96        (false, None)
97    };
98    let size_bytes = dir_size(dir).unwrap_or(0);
99    let locked = lock::probe(dir);
100    Ok(ProfileEntry {
101        backend: backend.to_string(),
102        name: name.to_string(),
103        path: dir.to_path_buf(),
104        size_bytes,
105        metadata_present,
106        last_used_at_rfc3339: last_used_at,
107        locked,
108    })
109}
110
111pub fn lock_status(
112    name: &str,
113    backend: Option<&str>,
114    profile_root: Option<&Path>,
115) -> Result<lock::LockStatus, Error> {
116    paths::validate_name(name)?;
117    let root = root_or_default(profile_root);
118    let (_, dir) = resolve_backend_scoped_profile(&root, name, backend)?;
119    Ok(lock::status(&dir))
120}
121
122pub fn downloads(
123    name: &str,
124    backend: Option<&str>,
125    profile_root: Option<&Path>,
126) -> Result<Vec<DownloadEntry>, Error> {
127    let entry = info(name, backend, profile_root)?;
128    downloads_at(&entry.path)
129}
130
131fn downloads_at(profile_dir: &Path) -> Result<Vec<DownloadEntry>, Error> {
132    let dir = profile_dir.join("downloads");
133    if !dir.exists() {
134        return Ok(Vec::new());
135    }
136    let read = std::fs::read_dir(&dir).map_err(|e| {
137        Error::new(
138            ErrorCode::IoError,
139            format!("read_dir({}): {e}", dir.display()),
140        )
141    })?;
142    let mut out = Vec::new();
143    for entry in read.flatten() {
144        let path = entry.path();
145        let Ok(meta) = entry.metadata() else {
146            continue;
147        };
148        if !meta.is_file() {
149            continue;
150        }
151        let Some(filename) = path.file_name().and_then(|name| name.to_str()) else {
152            continue;
153        };
154        let filename = filename.to_string();
155        let state = if path.extension().and_then(|ext| ext.to_str()) == Some("crdownload") {
156            "in_progress"
157        } else {
158            "completed"
159        };
160        let path = path.canonicalize().unwrap_or(path);
161        out.push(DownloadEntry {
162            filename,
163            path,
164            size_bytes: meta.len(),
165            state: state.to_string(),
166        });
167    }
168    out.sort_by(|a, b| a.filename.cmp(&b.filename));
169    Ok(out)
170}
171
172pub fn delete(
173    name: &str,
174    confirm: &str,
175    backend: Option<&str>,
176    profile_root: Option<&Path>,
177) -> Result<(), Error> {
178    paths::validate_name(name)?;
179    if name != confirm {
180        return Err(Error::new(
181            ErrorCode::InvalidArgument,
182            format!("profile delete: --confirm must match name (got {confirm:?})"),
183        ));
184    }
185    let root = root_or_default(profile_root);
186    let (backend, dir) = resolve_backend_scoped_profile(&root, name, backend)?;
187    if lock::probe(&dir) {
188        return Err(Error::new(
189            ErrorCode::ProfileDeleteLocked,
190            format!("profile {backend}/{name:?} is locked; cannot delete"),
191        ));
192    }
193    std::fs::remove_dir_all(&dir).map_err(|e| {
194        Error::new(
195            ErrorCode::IoError,
196            format!("remove_dir_all({}): {e}", dir.display()),
197        )
198    })
199}
200
201fn resolve_backend_scoped_profile(
202    root: &Path,
203    name: &str,
204    backend: Option<&str>,
205) -> Result<(String, PathBuf), Error> {
206    if let Some(backend) = backend {
207        paths::validate_backend_key(backend)?;
208        let dir = paths::join_root_backend_name(root, backend, name)?;
209        if dir.exists() {
210            return Ok((backend.to_string(), dir));
211        }
212        return Err(Error::new(
213            ErrorCode::ProfileNotFound,
214            format!(
215                "profile {name:?} not found for backend {backend:?} at {}",
216                dir.display()
217            ),
218        ));
219    }
220
221    let mut candidates = Vec::new();
222    for backend in paths::KNOWN_BACKENDS {
223        let dir = root.join(backend).join(name);
224        if dir.exists() {
225            candidates.push(((*backend).to_string(), dir));
226        }
227    }
228    match candidates.len() {
229        0 => Err(Error::new(
230            ErrorCode::ProfileNotFound,
231            format!(
232                "profile {name:?} not found under backend-scoped root {}",
233                root.display()
234            ),
235        )),
236        1 => Ok(candidates.remove(0)),
237        _ => Err(Error::new(
238            ErrorCode::InvalidArgument,
239            format!(
240                "profile {name:?} exists under multiple backends ({}); pass --backend <backend>",
241                candidates
242                    .iter()
243                    .map(|(backend, _)| backend.as_str())
244                    .collect::<Vec<_>>()
245                    .join(", ")
246            ),
247        )),
248    }
249}
250
251pub fn read_profile_meta(path: &Path) -> Result<meta::ProfileMeta, Error> {
252    let s = std::fs::read_to_string(path).map_err(|e| {
253        Error::new(
254            ErrorCode::IoError,
255            format!("read profile metadata {}: {e}", path.display()),
256        )
257    })?;
258    serde_json::from_str::<meta::ProfileMeta>(&s).map_err(|e| {
259        Error::new(
260            ErrorCode::ProfileInvalidName,
261            format!(
262                "profile metadata {} is not schema v{}: {e}; delete/recreate this profile",
263                path.display(),
264                meta::ProfileMeta::SCHEMA_VERSION
265            ),
266        )
267    })
268}
269
270pub fn validate_profile_meta(
271    meta: &meta::ProfileMeta,
272    backend: &str,
273    name: &str,
274    dir: &Path,
275) -> Result<(), Error> {
276    if meta.schema_version != meta::ProfileMeta::SCHEMA_VERSION {
277        return Err(Error::new(
278            ErrorCode::ProfileInvalidName,
279            format!(
280                "profile metadata at {} has schema_version {}; expected {}; delete/recreate this profile",
281                dir.join("afhttp-profile.json").display(),
282                meta.schema_version,
283                meta::ProfileMeta::SCHEMA_VERSION
284            ),
285        ));
286    }
287    if meta.backend != backend {
288        return Err(Error::new(
289            ErrorCode::ProfileInvalidName,
290            format!(
291                "profile metadata backend {:?} does not match path backend {:?} at {}; delete/recreate this profile",
292                meta.backend,
293                backend,
294                dir.display()
295            ),
296        ));
297    }
298    if meta.name != name {
299        return Err(Error::new(
300            ErrorCode::ProfileInvalidName,
301            format!(
302                "profile metadata name {:?} does not match path name {:?} at {}; delete/recreate this profile",
303                meta.name,
304                name,
305                dir.display()
306            ),
307        ));
308    }
309    Ok(())
310}
311
312pub fn prune(
313    older_than: Duration,
314    dry_run: bool,
315    profile_root: Option<&Path>,
316) -> Result<Vec<ProfileEntry>, Error> {
317    let entries = list(profile_root)?;
318    let now = SystemTime::now();
319    let mut removed = Vec::new();
320    for entry in entries {
321        if entry.locked {
322            continue;
323        }
324        let too_old = match std::fs::metadata(&entry.path) {
325            Ok(m) => match m.modified() {
326                Ok(t) => match now.duration_since(t) {
327                    Ok(age) => age >= older_than,
328                    Err(_) => false,
329                },
330                Err(_) => false,
331            },
332            Err(_) => false,
333        };
334        if !too_old {
335            continue;
336        }
337        if !dry_run {
338            std::fs::remove_dir_all(&entry.path).map_err(|e| {
339                Error::new(
340                    ErrorCode::IoError,
341                    format!("remove_dir_all({}): {e}", entry.path.display()),
342                )
343            })?;
344        }
345        removed.push(entry);
346    }
347    Ok(removed)
348}
349
350fn dir_size(dir: &Path) -> std::io::Result<u64> {
351    let mut total: u64 = 0;
352    let mut stack = vec![dir.to_path_buf()];
353    while let Some(p) = stack.pop() {
354        for entry in std::fs::read_dir(&p)?.flatten() {
355            let path = entry.path();
356            let md = match entry.metadata() {
357                Ok(m) => m,
358                Err(_) => continue,
359            };
360            if md.is_file() {
361                total = total.saturating_add(md.len());
362            } else if md.is_dir() {
363                stack.push(path);
364            }
365        }
366    }
367    Ok(total)
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    fn touch_profile(root: &Path, backend: &str, name: &str) -> PathBuf {
375        let dir = root.join(backend).join(name);
376        std::fs::create_dir_all(&dir).unwrap();
377        let meta = meta::ProfileMeta::new(name, backend);
378        std::fs::write(
379            dir.join("afhttp-profile.json"),
380            serde_json::to_string(&meta).unwrap(),
381        )
382        .unwrap();
383        dir
384    }
385
386    #[test]
387    fn lists_existing_profiles() {
388        let tmp = tempfile::tempdir().unwrap();
389        touch_profile(tmp.path(), "brave", "work");
390        touch_profile(tmp.path(), "chromium", "alpha");
391        std::fs::create_dir_all(tmp.path().join("old-layout")).unwrap();
392        let entries = list(Some(tmp.path())).unwrap();
393        assert_eq!(entries.len(), 2);
394        assert_eq!(entries[0].backend, "brave"); // sorted by backend, then name
395        assert_eq!(entries[0].name, "work");
396        assert_eq!(entries[1].backend, "chromium");
397        assert_eq!(entries[1].name, "alpha");
398        assert!(entries[0].metadata_present);
399    }
400
401    #[test]
402    fn info_returns_profile_details() {
403        let tmp = tempfile::tempdir().unwrap();
404        touch_profile(tmp.path(), "brave", "work");
405        let entry = info("work", Some("brave"), Some(tmp.path())).unwrap();
406        assert_eq!(entry.name, "work");
407        assert_eq!(entry.backend, "brave");
408        assert!(entry.metadata_present);
409        assert!(!entry.locked);
410    }
411
412    #[test]
413    fn downloads_lists_profile_download_artifacts() {
414        let tmp = tempfile::tempdir().unwrap();
415        let profile = touch_profile(tmp.path(), "brave", "work");
416        let download_dir = profile.join("downloads");
417        std::fs::create_dir_all(&download_dir).unwrap();
418        std::fs::write(download_dir.join("report.csv"), "abc").unwrap();
419        std::fs::write(download_dir.join("pending.bin.crdownload"), "partial").unwrap();
420
421        let entries = downloads("work", Some("brave"), Some(tmp.path())).unwrap();
422        assert_eq!(entries.len(), 2);
423        assert_eq!(entries[0].filename, "pending.bin.crdownload");
424        assert_eq!(entries[0].state, "in_progress");
425        assert_eq!(entries[1].filename, "report.csv");
426        assert_eq!(entries[1].size_bytes, 3);
427        assert_eq!(entries[1].state, "completed");
428    }
429
430    #[test]
431    fn info_missing_profile_returns_not_found() {
432        let tmp = tempfile::tempdir().unwrap();
433        let err = info("nope", Some("brave"), Some(tmp.path())).err().unwrap();
434        assert_eq!(err.error_code, ErrorCode::ProfileNotFound);
435    }
436
437    #[test]
438    fn delete_requires_matching_confirm() {
439        let tmp = tempfile::tempdir().unwrap();
440        touch_profile(tmp.path(), "brave", "work");
441        let err = delete("work", "different", Some("brave"), Some(tmp.path()))
442            .err()
443            .unwrap();
444        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
445    }
446
447    #[test]
448    fn delete_removes_profile() {
449        let tmp = tempfile::tempdir().unwrap();
450        let dir = touch_profile(tmp.path(), "brave", "work");
451        delete("work", "work", Some("brave"), Some(tmp.path())).unwrap();
452        assert!(!dir.exists());
453    }
454
455    #[test]
456    fn delete_missing_returns_not_found() {
457        let tmp = tempfile::tempdir().unwrap();
458        let err = delete("nope", "nope", Some("brave"), Some(tmp.path()))
459            .err()
460            .unwrap();
461        assert_eq!(err.error_code, ErrorCode::ProfileNotFound);
462    }
463
464    #[test]
465    fn delete_locked_refuses() {
466        let tmp = tempfile::tempdir().unwrap();
467        let dir = touch_profile(tmp.path(), "brave", "work");
468        // Hold the lock for the duration of the assertion.
469        let _g = lock::Guard::acquire(&dir).unwrap();
470        let err = delete("work", "work", Some("brave"), Some(tmp.path()))
471            .err()
472            .unwrap();
473        assert_eq!(err.error_code, ErrorCode::ProfileDeleteLocked);
474    }
475
476    #[test]
477    fn prune_dry_run_does_not_delete() {
478        let tmp = tempfile::tempdir().unwrap();
479        let dir = touch_profile(tmp.path(), "brave", "work");
480        // Set mtime far in the past.
481        let two_hours = SystemTime::now() - Duration::from_secs(7200);
482        filetime::set_file_mtime(&dir, filetime::FileTime::from_system_time(two_hours)).ok();
483        let removed = prune(Duration::from_secs(3600), true, Some(tmp.path())).unwrap();
484        assert_eq!(removed.len(), 1);
485        assert!(dir.exists());
486    }
487
488    #[test]
489    fn prune_removes_old_profiles() {
490        let tmp = tempfile::tempdir().unwrap();
491        let dir = touch_profile(tmp.path(), "brave", "work");
492        let two_hours = SystemTime::now() - Duration::from_secs(7200);
493        filetime::set_file_mtime(&dir, filetime::FileTime::from_system_time(two_hours)).ok();
494        let removed = prune(Duration::from_secs(3600), false, Some(tmp.path())).unwrap();
495        assert_eq!(removed.len(), 1);
496        assert!(!dir.exists());
497    }
498
499    #[test]
500    fn same_name_multiple_backends_requires_backend() {
501        let tmp = tempfile::tempdir().unwrap();
502        touch_profile(tmp.path(), "brave", "work");
503        touch_profile(tmp.path(), "chromium", "work");
504        let err = info("work", None, Some(tmp.path())).err().unwrap();
505        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
506        assert!(err.detail.contains("--backend"));
507
508        let chromium = info("work", Some("chromium"), Some(tmp.path())).unwrap();
509        assert_eq!(chromium.backend, "chromium");
510    }
511
512    #[test]
513    fn same_name_different_backends_have_independent_locks_and_downloads() {
514        let tmp = tempfile::tempdir().unwrap();
515        let brave = touch_profile(tmp.path(), "brave", "work");
516        let chromium = touch_profile(tmp.path(), "chromium", "work");
517        let _guard = lock::Guard::acquire(&brave).unwrap();
518
519        assert!(
520            lock_status("work", Some("brave"), Some(tmp.path()))
521                .unwrap()
522                .locked
523        );
524        assert!(
525            !lock_status("work", Some("chromium"), Some(tmp.path()))
526                .unwrap()
527                .locked
528        );
529
530        std::fs::create_dir_all(brave.join("downloads")).unwrap();
531        std::fs::create_dir_all(chromium.join("downloads")).unwrap();
532        std::fs::write(brave.join("downloads").join("brave.txt"), "a").unwrap();
533        std::fs::write(chromium.join("downloads").join("chromium.txt"), "b").unwrap();
534        let brave_downloads = downloads("work", Some("brave"), Some(tmp.path())).unwrap();
535        let chromium_downloads = downloads("work", Some("chromium"), Some(tmp.path())).unwrap();
536        assert_eq!(brave_downloads[0].filename, "brave.txt");
537        assert_eq!(chromium_downloads[0].filename, "chromium.txt");
538    }
539
540    #[test]
541    fn metadata_backend_mismatch_is_rejected() {
542        let tmp = tempfile::tempdir().unwrap();
543        let dir = tmp.path().join("brave").join("work");
544        std::fs::create_dir_all(&dir).unwrap();
545        let meta = meta::ProfileMeta::new("work", "chromium");
546        std::fs::write(
547            dir.join("afhttp-profile.json"),
548            serde_json::to_string(&meta).unwrap(),
549        )
550        .unwrap();
551        let err = info("work", Some("brave"), Some(tmp.path())).err().unwrap();
552        assert_eq!(err.error_code, ErrorCode::ProfileInvalidName);
553        assert!(err.detail.contains("backend"));
554    }
555}