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, and between the files a search reads; when it answers `true`
56    /// the command or the search is stopped.
57    pub fn perform(&self, action: &Action, cancelled: &dyn Fn() -> bool) -> Outcome {
58        let result = match action {
59            Action::Read { path, lines } => read::read(self, path, *lines),
60            Action::List { path, depth } => list::list(self, path, *depth),
61            Action::Search { pattern, path, glob, ignore_case } => {
62                search::search(self, pattern, path, glob.as_deref(), *ignore_case, cancelled, search::LIMIT)
63            }
64            Action::Edit { path, old_text, new_text } => edit::edit(self, path, old_text, new_text),
65            Action::Run { command } => return run::run(self, command, cancelled, run::LIMIT),
66        };
67        match result {
68            Ok(text) => Outcome::done(text),
69            Err(text) => Outcome::failed(text),
70        }
71    }
72
73    /// Does the action the person allowed on a card that showed `shown`. An edit whose file
74    /// changed since the card was drawn, so that what would be written is no longer what the
75    /// person saw, is not done: the model is told to read the file again.
76    pub fn perform_as_shown(&self, action: &Action, shown: Option<&Preview>, cancelled: &dyn Fn() -> bool) -> Outcome {
77        self.perform_watching(action, shown, cancelled, &mut |_| {})
78    }
79
80    /// [`perform_as_shown`](Self::perform_as_shown), handing the end of a command's output to
81    /// `on_tail` while it runs, at most every tenth of a second, so the person sees it go by.
82    pub fn perform_watching(
83        &self,
84        action: &Action,
85        shown: Option<&Preview>,
86        cancelled: &dyn Fn() -> bool,
87        on_tail: &mut dyn FnMut(&str),
88    ) -> Outcome {
89        if let Action::Run { command } = action {
90            return run::run_watching(self, command, cancelled, run::LIMIT, on_tail);
91        }
92        if let (Action::Edit { path, old_text, new_text }, Some(shown)) = (action, shown) {
93            let now = edit::plan(self, path, old_text, new_text).map(|plan| plan.preview());
94            if now.as_ref().is_ok_and(|now| now != shown) {
95                return Outcome::failed(format!(
96                    "`{path}` changed after the person was shown this edit, so nothing was written; read it again and ask anew."
97                ));
98            }
99        }
100        self.perform(action, cancelled)
101    }
102
103    /// Finds the real location of `path` and makes sure it is inside the workspace.
104    ///
105    /// The path is relative to the root; an absolute path is accepted only when it lands inside.
106    /// Links are followed: an existing path is canonicalized, and for one that does not exist yet
107    /// its nearest existing ancestor is, with the missing names added back. Anything whose real
108    /// location is outside the root is refused.
109    pub(super) fn resolve(&self, path: &str) -> Result<PathBuf, String> {
110        let given = Path::new(path);
111        let joined = if given.is_absolute() { given.to_path_buf() } else { self.root.join(given) };
112        let mut existing = joined.as_path();
113        let mut missing = Vec::new();
114        while fs::symlink_metadata(existing).is_err() {
115            // A missing name followed by `..` cannot be resolved without guessing, so it is
116            // refused rather than read lexically.
117            match (existing.file_name(), existing.parent()) {
118                (Some(name), Some(parent)) => {
119                    missing.push(name);
120                    existing = parent;
121                }
122                _ => {
123                    return Err(format!("`{path}` cannot be resolved; use a plain path inside the working folder."));
124                }
125            }
126        }
127        let real = fs::canonicalize(existing).map_err(|_| {
128            format!("`{path}` goes through a link that leads nowhere; use a path inside the working folder.")
129        })?;
130        let real = missing.iter().rev().fold(real, |acc, name| acc.join(name));
131        if real.starts_with(&self.root) {
132            Ok(real)
133        } else {
134            Err(format!("`{path}` is outside the working folder; only paths inside it can be used."))
135        }
136    }
137
138    /// A resolved path as the model sees it: relative to the root, `.` for the root itself.
139    pub(super) fn relative(&self, real: &Path) -> String {
140        let relative = real.strip_prefix(&self.root).unwrap_or(real);
141        if relative.as_os_str().is_empty() { ".".to_owned() } else { relative.to_string_lossy().into_owned() }
142    }
143}
144
145/// How much of one file is ever read into memory. The model reads without asking, so a
146/// many-gigabyte log in the folder must not be able to take the machine's memory.
147pub(super) const READ_CAP: u64 = 16 * 1024 * 1024;
148
149/// What was read of a file: at most [`READ_CAP`] of its bytes, and how big it is.
150pub(super) struct Contents {
151    /// The bytes read, the whole file when it is not over the cap.
152    pub(super) bytes: Vec<u8>,
153    /// The size of the whole file.
154    pub(super) size: u64,
155}
156
157impl Contents {
158    /// Whether the bytes are the whole file.
159    pub(super) fn whole(&self) -> bool {
160        self.size <= READ_CAP
161    }
162}
163
164/// Reads the file `real`, at most [`READ_CAP`] of it. `Ok(None)` when it is not a regular file:
165/// a named pipe would wait for a writer that never comes, and a device can be endless, so
166/// neither is opened at all.
167pub(super) fn read_capped(real: &Path) -> io::Result<Option<Contents>> {
168    use std::io::Read as _;
169    let meta = fs::metadata(real)?;
170    if !meta.is_file() {
171        return Ok(None);
172    }
173    let mut bytes = Vec::new();
174    fs::File::open(real)?.take(READ_CAP).read_to_end(&mut bytes)?;
175    // A file that grew since it was looked at is at least as big as what was read of it.
176    let size = meta.len().max(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
177    Ok(Some(Contents { bytes, size }))
178}
179
180/// What the model is told about a path that is neither a file nor a folder.
181pub(super) fn not_regular(shown: &str) -> String {
182    format!("`{shown}` is not a regular file (a pipe, a device or a socket); it cannot be read or edited.")
183}
184
185/// A file-system failure as a sentence for the model.
186pub(super) fn io_problem(shown: &str, error: &io::Error) -> String {
187    match error.kind() {
188        io::ErrorKind::NotFound => format!("`{shown}` does not exist."),
189        io::ErrorKind::PermissionDenied => format!("`{shown}` cannot be used: permission denied."),
190        _ => format!("`{shown}` cannot be used: {error}."),
191    }
192}