Skip to main content

agent_skill_installer/
consent.rs

1// SPDX-License-Identifier: LGPL-3.0-or-later
2// Copyright (C) 2026 Fabian Schmieder
3
4//! Which targets the user said no to (D5).
5//!
6//! Kept by the calling program, in a file in its own data directory. Nothing
7//! about consent is written into the tools' directories: an installed skill is
8//! itself the record that the user said yes, and a "no" belongs to the program
9//! that asked.
10
11use std::collections::BTreeSet;
12use std::fs;
13use std::io;
14use std::path::{Path, PathBuf};
15
16const HEADER: &str = "# agent-skill-installer: targets the user declined, one per line";
17
18/// The declined targets, and the file they are kept in.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Consent {
21    path: PathBuf,
22    declined: BTreeSet<PathBuf>,
23}
24
25impl Consent {
26    /// The store kept at `path`. A file that does not exist yet is an empty
27    /// store.
28    ///
29    /// # Errors
30    /// Returns an error when the file exists but cannot be read.
31    pub fn load(path: impl Into<PathBuf>) -> io::Result<Self> {
32        let path = path.into();
33        let declined = match fs::read_to_string(&path) {
34            Ok(text) => text
35                .lines()
36                .filter(|line| !line.is_empty() && !line.starts_with('#'))
37                .map(PathBuf::from)
38                .collect(),
39            Err(error) if error.kind() == io::ErrorKind::NotFound => BTreeSet::new(),
40            Err(error) => return Err(error),
41        };
42        Ok(Self { path, declined })
43    }
44
45    /// Whether the user declined this target.
46    #[must_use]
47    pub fn is_declined(&self, target: &Path) -> bool {
48        self.declined.contains(target)
49    }
50
51    /// Record that the user declined this target.
52    ///
53    /// # Errors
54    /// Returns an error for a path that cannot be stored on one line.
55    pub fn decline(&mut self, target: &Path) -> io::Result<()> {
56        let text = target.to_string_lossy();
57        if text.contains('\n') || text.contains('\r') || text.as_ref() != target.as_os_str() {
58            return Err(io::Error::new(
59                io::ErrorKind::InvalidInput,
60                format!("{} cannot be stored as one line of text", target.display()),
61            ));
62        }
63        self.declined.insert(target.to_path_buf());
64        Ok(())
65    }
66
67    /// Forget that the user declined this target, so it is asked about again.
68    pub fn withdraw(&mut self, target: &Path) {
69        self.declined.remove(target);
70    }
71
72    /// Write the store, replacing the file in one step.
73    ///
74    /// # Errors
75    /// Returns an error when the file or its directory cannot be written.
76    pub fn save(&self) -> io::Result<()> {
77        if let Some(parent) = self.path.parent() {
78            fs::create_dir_all(parent)?;
79        }
80        let mut text = format!("{HEADER}\n");
81        for target in &self.declined {
82            text.push_str(&target.to_string_lossy());
83            text.push('\n');
84        }
85        let staging = self.path.with_extension("tmp");
86        let mut file = fs::File::create(&staging)?;
87        std::io::Write::write_all(&mut file, text.as_bytes())?;
88        // On disk before the rename, so a crash leaves the old answers or the
89        // new ones, never an empty file.
90        file.sync_all()?;
91        drop(file);
92        fs::rename(&staging, &self.path)
93    }
94}