Skip to main content

qcli/actions/
workspace.rs

1//! The workspace folder: confining paths to it and dispatching actions.
2
3use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6
7use super::{Action, Outcome, Preview, edit, list, read, run, search};
8
9/// The folder the actions are confined to.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Workspace {
12    root: PathBuf,
13}
14
15impl Workspace {
16    /// The folder the actions are confined to; it is canonicalized, so links are resolved once.
17    ///
18    /// # Errors
19    ///
20    /// When the folder does not exist, cannot be resolved or is not a folder.
21    pub fn new(root: &Path) -> io::Result<Self> {
22        let root = fs::canonicalize(root)?;
23        if !root.is_dir() {
24            return Err(io::Error::new(io::ErrorKind::NotADirectory, "the workspace must be a folder"));
25        }
26        Ok(Self { root })
27    }
28
29    /// The canonical workspace folder.
30    #[must_use]
31    pub fn root(&self) -> &Path {
32        &self.root
33    }
34
35    /// What an action would do, for the approval card. Nothing changes on disk.
36    ///
37    /// # Errors
38    ///
39    /// When the action cannot be done as asked (a path outside the workspace, an edit whose text
40    /// does not match exactly once); the sentence is the one [`Self::perform`] would give.
41    pub fn preview(&self, action: &Action) -> Result<Preview, String> {
42        match action {
43            Action::Read { path } | Action::List { path } | Action::Search { path, .. } => {
44                self.resolve(path).map(|_| Preview::Plain)
45            }
46            Action::Edit { path, old_text, new_text } => {
47                edit::plan(self, path, old_text, new_text).map(|plan| plan.preview())
48            }
49            Action::Run { command } => Ok(Preview::Command { command: command.clone() }),
50        }
51    }
52
53    /// Does the action. It never panics: every failure becomes an [`Outcome`] with
54    /// `is_error` set and a sentence the model can act on. `cancelled` is asked about every
55    /// 50 ms while a command runs; when it answers `true` the command is stopped.
56    pub fn perform(&self, action: &Action, cancelled: &dyn Fn() -> bool) -> Outcome {
57        let result = match action {
58            Action::Read { path } => read::read(self, path),
59            Action::List { path } => list::list(self, path),
60            Action::Search { pattern, path } => search::search(self, pattern, path),
61            Action::Edit { path, old_text, new_text } => edit::edit(self, path, old_text, new_text),
62            Action::Run { command } => return run::run(self, command, cancelled, run::LIMIT),
63        };
64        match result {
65            Ok(text) => Outcome::done(text),
66            Err(text) => Outcome::failed(text),
67        }
68    }
69
70    /// Finds the real location of `path` and makes sure it is inside the workspace.
71    ///
72    /// The path is relative to the root; an absolute path is accepted only when it lands inside.
73    /// Links are followed: an existing path is canonicalized, and for one that does not exist yet
74    /// its nearest existing ancestor is, with the missing names added back. Anything whose real
75    /// location is outside the root is refused.
76    pub(super) fn resolve(&self, path: &str) -> Result<PathBuf, String> {
77        let given = Path::new(path);
78        let joined = if given.is_absolute() { given.to_path_buf() } else { self.root.join(given) };
79        let mut existing = joined.as_path();
80        let mut missing = Vec::new();
81        while fs::symlink_metadata(existing).is_err() {
82            // A missing name followed by `..` cannot be resolved without guessing, so it is
83            // refused rather than read lexically.
84            match (existing.file_name(), existing.parent()) {
85                (Some(name), Some(parent)) => {
86                    missing.push(name);
87                    existing = parent;
88                }
89                _ => {
90                    return Err(format!("`{path}` cannot be resolved; use a plain path inside the working folder."));
91                }
92            }
93        }
94        let real = fs::canonicalize(existing).map_err(|_| {
95            format!("`{path}` goes through a link that leads nowhere; use a path inside the working folder.")
96        })?;
97        let real = missing.iter().rev().fold(real, |acc, name| acc.join(name));
98        if real.starts_with(&self.root) {
99            Ok(real)
100        } else {
101            Err(format!("`{path}` is outside the working folder; only paths inside it can be used."))
102        }
103    }
104
105    /// A resolved path as the model sees it: relative to the root, `.` for the root itself.
106    pub(super) fn relative(&self, real: &Path) -> String {
107        let relative = real.strip_prefix(&self.root).unwrap_or(real);
108        if relative.as_os_str().is_empty() { ".".to_owned() } else { relative.to_string_lossy().into_owned() }
109    }
110}
111
112/// A file-system failure as a sentence for the model.
113pub(super) fn io_problem(shown: &str, error: &io::Error) -> String {
114    match error.kind() {
115        io::ErrorKind::NotFound => format!("`{shown}` does not exist."),
116        io::ErrorKind::PermissionDenied => format!("`{shown}` cannot be used: permission denied."),
117        _ => format!("`{shown}` cannot be used: {error}."),
118    }
119}