Skip to main content

qcode/store/
layout.rs

1//! The folder tree of the store: creating it, and reading it back.
2//!
3//! ```text
4//! {store}/
5//!   Profiles/
6//!     <profile>.toml
7//!     custom/
8//!       <profile>.toml
9//!   Workspaces/
10//!     <workspace>/
11//!       workspace.qcode
12//!       Work/
13//!       Assets/
14//!       Containers/Harness/<profile>/
15//! ```
16//!
17//! A profile whose parts are its own is written in `Profiles/custom/`, so that a QCode from
18//! before that folder is there does not read it and build an image the file never asked for.
19//!
20//! `Backup/` and `Containers/MCP/` are not created here; they are born with the features that
21//! need them. Nothing in this module panics: a store that cannot be read or written is a
22//! [`Diagnostic`], and a workspace folder that is broken or half-made is listed as broken beside
23//! the ones that are fine.
24
25use std::fs;
26use std::io;
27use std::path::{Path, PathBuf};
28
29use qframe::date::Date;
30use qframe::diagnostics::Diagnostic;
31use qframe::storage::atomic_write;
32use qframe::t;
33
34use crate::backup::Place;
35use crate::profile::{Profile, Template};
36
37use super::workspace::outside;
38use super::{Loaded, WorkspaceFile, WorkspaceId, WorkspaceIdError, WorkspaceProfile};
39
40/// The name of a workspace's own file.
41const WORKSPACE_FILE: &str = "workspace.qcode";
42
43/// The folder the profile definitions live in.
44const PROFILES: &str = "Profiles";
45
46/// The folder inside it the definitions of profiles whose parts are their own live in.
47const CUSTOM: &str = "custom";
48
49/// The folder the workspaces live in.
50const WORKSPACES: &str = "Workspaces";
51
52/// The folder the user's code lives in, inside a workspace. It carries the name the container
53/// mounts it on ([`crate::base::paths::CODE_DIR`]), so the person reads one word in both places.
54const CODE: &str = "Work";
55
56/// The names a store written by an older QCode carries, in the order they were left behind:
57/// first the one from before workspaces had their name, then the one from when the person's own
58/// folder was called `Code/`. [`Store::adopt`] gives each one the name it has today, and a store
59/// that stopped at either of them is carried the rest of the way.
60const LEGACY: [(&str, &str); 3] = [("project.qcode", WORKSPACE_FILE), ("Project", CODE), ("Code", CODE)];
61
62/// The same for the two that are not beside each other: the folder of all workspaces, and the
63/// snapshots of one workspace's code inside its `Backup/`.
64const LEGACY_WORKSPACES: &str = "Projects";
65
66/// Why a new workspace could not be made.
67#[derive(Debug)]
68pub enum NewWorkspaceError {
69    /// The display name yields no identifier.
70    Name(WorkspaceIdError),
71    /// A workspace of that identifier is already there. This layer never quietly adds a number:
72    /// the caller tells the user and asks for another name.
73    Taken(WorkspaceId),
74    /// The store could not be written.
75    Blocked(Diagnostic),
76}
77
78/// Where everything of one workspace is.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct WorkspacePaths {
81    /// The workspace's own folder.
82    pub root: PathBuf,
83    /// The workspace's `workspace.qcode`.
84    pub file: PathBuf,
85    /// The folder the user's code lives in.
86    pub code: PathBuf,
87    /// The folder the user's other material lives in.
88    pub assets: PathBuf,
89    /// The folder that holds one folder per profile the workspace carries.
90    pub harness: PathBuf,
91}
92
93impl WorkspacePaths {
94    /// The folder of one profile of this workspace.
95    #[must_use]
96    pub fn harness_profile(&self, profile: &str) -> PathBuf {
97        self.harness.join(profile)
98    }
99
100    /// The folder the bridge between the workspace's tabs lives in: the socket QCode answers on
101    /// while the workspace is open, and the server the harnesses start. Every profile container
102    /// of the workspace sees it read-only.
103    ///
104    /// Not made with the workspace: opening it makes the folder, so a workspace never opened since
105    /// the bridge came has none.
106    #[must_use]
107    pub fn mcp(&self) -> PathBuf {
108        self.root.join("Containers").join("MCP")
109    }
110
111    /// The folder a window's container leaves web addresses in, for QCode to open in the
112    /// person's own browser.
113    ///
114    /// Not made with the workspace: opening a window makes it, so a workspace that has never opened
115    /// one has none.
116    #[must_use]
117    pub fn browser(&self) -> PathBuf {
118        self.root.join("Containers").join("Browser")
119    }
120
121    /// The folder the workspace's backups are kept in.
122    ///
123    /// Not made with the workspace: the first backup makes it, so a workspace that has never been
124    /// backed up has no folder claiming otherwise.
125    #[must_use]
126    pub fn backup(&self) -> PathBuf {
127        self.root.join("Backup")
128    }
129}
130
131/// One workspace folder as it was found on disk.
132#[derive(Debug, Clone, PartialEq)]
133pub struct WorkspaceEntry {
134    /// The workspace's folder.
135    pub dir: PathBuf,
136    /// What its `workspace.qcode` says, when the folder names a workspace at all.
137    pub file: Option<WorkspaceFile>,
138    /// Everything wrong with this workspace. Empty means it is whole.
139    pub problems: Vec<Diagnostic>,
140}
141
142impl WorkspaceEntry {
143    /// Whether anything about this workspace is broken or missing. A broken workspace is still
144    /// listed, with its problems, so the user can see it and repair it.
145    #[must_use]
146    pub fn is_broken(&self) -> bool {
147        !self.problems.is_empty()
148    }
149}
150
151/// Moves `from` to `to`, when `from` is there and `to` is not.
152fn adopt_name(from: &Path, to: &Path, said: &mut Vec<Diagnostic>) {
153    if fs::symlink_metadata(from).is_err() {
154        return;
155    }
156    if fs::symlink_metadata(to).is_ok() {
157        let message = format!("{} is there as well; {} is left as it is", to.display(), from.display());
158        said.push(Diagnostic::warning(None, message));
159        return;
160    }
161    if let Err(error) = fs::rename(from, to) {
162        let message =
163            format!("{} could not be renamed to {} ({error}); it stays where it is", from.display(), to.display());
164        said.push(Diagnostic::error(None, message));
165    }
166}
167
168/// A store directory: the workspaces and profiles of one installation.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct Store {
171    root: PathBuf,
172}
173
174impl Store {
175    /// The store at `root`. Nothing is read or written until it is asked for.
176    #[must_use]
177    pub fn new(root: impl Into<PathBuf>) -> Self {
178        Self { root: root.into() }
179    }
180
181    /// The store directory itself.
182    #[must_use]
183    pub fn root(&self) -> &Path {
184        &self.root
185    }
186
187    /// The folder the profile definitions live in.
188    #[must_use]
189    pub fn profiles_dir(&self) -> PathBuf {
190        self.root.join(PROFILES)
191    }
192
193    /// The folder the workspaces live in.
194    #[must_use]
195    pub fn workspaces_dir(&self) -> PathBuf {
196        self.root.join(WORKSPACES)
197    }
198
199    /// Creates the folders the store needs, and proves they can be written.
200    ///
201    /// Safe to call at every start: a store that is already there is left alone.
202    ///
203    /// # Errors
204    ///
205    /// A diagnostic naming the directory and the reason, when it cannot be created or written.
206    pub fn prepare(&self) -> Result<(), Diagnostic> {
207        for dir in [self.profiles_dir(), self.workspaces_dir()] {
208            fs::create_dir_all(&dir).map_err(|error| blocked(&dir, &error))?;
209        }
210        Ok(())
211    }
212
213    /// Gives every name this store carries from an older QCode the name it has today: `Projects/`
214    /// becomes `Workspaces/`, and inside each workspace `project.qcode` becomes `workspace.qcode`,
215    /// `Project/` and then `Code/` become `Work/`, and `Backup/Project.git` becomes
216    /// `Backup/Code.git`.
217    ///
218    /// Each move is a single rename, so an interruption leaves the old name whole rather than two
219    /// halves, and nothing is renamed onto something that is there already: a store holding both
220    /// names is left as it is and said. Safe to call at every start — a store with none of the old
221    /// names only looks.
222    pub fn adopt(&self) -> Vec<Diagnostic> {
223        let mut said = Vec::new();
224        adopt_name(&self.root.join(LEGACY_WORKSPACES), &self.workspaces_dir(), &mut said);
225        let Ok(entries) = fs::read_dir(self.workspaces_dir()) else {
226            return said;
227        };
228        for entry in entries.flatten() {
229            let dir = entry.path();
230            if !dir.is_dir() {
231                continue;
232            }
233            for (old, new) in LEGACY {
234                adopt_name(&dir.join(old), &dir.join(new), &mut said);
235            }
236            let backup = dir.join("Backup");
237            adopt_name(&backup.join("Project.git"), &backup.join(crate::backup::CODE_SNAPSHOTS), &mut said);
238        }
239        said
240    }
241
242    /// Where everything of the workspace `id` is, whether or not it exists yet.
243    #[must_use]
244    pub fn workspace_paths(&self, id: &WorkspaceId) -> WorkspacePaths {
245        let root = self.workspaces_dir().join(id.as_str());
246        WorkspacePaths {
247            file: root.join(WORKSPACE_FILE),
248            code: root.join(CODE),
249            assets: root.join("Assets"),
250            harness: root.join("Containers").join("Harness"),
251            root,
252        }
253    }
254
255    /// Where everything of the workspace `file` describes is: [`Store::workspace_paths`], with the
256    /// person's own folder as the code folder when the workspace works in one where it stands.
257    ///
258    /// Everything that opens a workspace reads its folders from here, so the file tree, the shell,
259    /// every container's work folder and the backup all see the same folder.
260    #[must_use]
261    pub fn paths_of(&self, file: &WorkspaceFile) -> WorkspacePaths {
262        let mut paths = self.workspace_paths(&file.id);
263        if let Some(folder) = &file.folder {
264            paths.code.clone_from(folder);
265        }
266        paths
267    }
268
269    /// Makes a workspace the user called `name` and gives it the tree of the design.
270    ///
271    /// # Errors
272    ///
273    /// [`NewWorkspaceError::Name`] when the name yields no identifier, [`NewWorkspaceError::Taken`]
274    /// when a folder of that identifier is already there — including one that only differs in
275    /// case, which a file system that ignores case would treat as the same folder — and
276    /// [`NewWorkspaceError::Blocked`] when the store cannot be written.
277    pub fn create_workspace(&self, name: &str, created: Date) -> Result<WorkspaceFile, NewWorkspaceError> {
278        self.create_workspace_in(name, created, None)
279    }
280
281    /// Makes a workspace the user called `name` that works in `folder` where it stands, or in a
282    /// `Work/` of its own when `folder` is `None`.
283    ///
284    /// The person's folder is only named in the workspace file: nothing is made, moved or written
285    /// in it.
286    ///
287    /// # Errors
288    ///
289    /// As [`Store::create_workspace`].
290    pub fn create_workspace_in(
291        &self,
292        name: &str,
293        created: Date,
294        folder: Option<PathBuf>,
295    ) -> Result<WorkspaceFile, NewWorkspaceError> {
296        let id = WorkspaceId::from_display_name(name).map_err(NewWorkspaceError::Name)?;
297        self.prepare().map_err(NewWorkspaceError::Blocked)?;
298        for taken in self.folder_names().map_err(NewWorkspaceError::Blocked)? {
299            if id.clashes_with(&taken) {
300                return Err(NewWorkspaceError::Taken(id));
301            }
302        }
303        let mut file = WorkspaceFile::new(id, name, created);
304        file.folder = folder;
305        self.write_workspace(&file).map_err(NewWorkspaceError::Blocked)?;
306        Ok(file)
307    }
308
309    /// Writes a workspace's `workspace.qcode` atomically and makes sure its folders — including one
310    /// per profile it names — are there.
311    ///
312    /// # Errors
313    ///
314    /// A diagnostic naming the directory or file that could not be written.
315    pub fn write_workspace(&self, file: &WorkspaceFile) -> Result<(), Diagnostic> {
316        write_workspace_at(&self.workspace_paths(&file.id), file)
317    }
318
319    /// Reads one workspace's `workspace.qcode`.
320    #[must_use]
321    pub fn read_workspace(&self, id: &WorkspaceId) -> Loaded<Option<WorkspaceFile>> {
322        let path = self.workspace_paths(id).file;
323        match fs::read_to_string(&path) {
324            Ok(text) => WorkspaceFile::parse(WORKSPACE_FILE, &text),
325            Err(error) => Loaded { value: None, diagnostics: vec![blocked(&path, &error)] },
326        }
327    }
328
329    /// Every workspace folder of the store, sorted by folder name.
330    ///
331    /// A folder that is broken or half-made is listed with its problems rather than left out:
332    /// the user cannot repair what the list refuses to show. A store that cannot be read at
333    /// all yields an empty list and one diagnostic.
334    #[must_use]
335    pub fn workspaces(&self) -> Loaded<Vec<WorkspaceEntry>> {
336        let names = match self.folder_names() {
337            Ok(names) => names,
338            Err(problem) => return Loaded { value: Vec::new(), diagnostics: vec![problem] },
339        };
340        let mut workspaces = Vec::with_capacity(names.len());
341        let mut diagnostics = Vec::new();
342        for name in names {
343            let entry = self.read_entry(&name);
344            diagnostics.extend(entry.problems.iter().cloned());
345            workspaces.push(entry);
346        }
347        Loaded { value: workspaces, diagnostics }
348    }
349
350    /// Every profile definition of the store, sorted by file name.
351    ///
352    /// A `Profiles/` folder that is not there yet is not a problem: it means no profiles. A file
353    /// that cannot be read or cannot be understood is reported and the rest are still returned,
354    /// so one broken definition never hides the profiles that work.
355    ///
356    /// Custom profiles live in [`Store::custom_profiles_dir`], which is read as well: their parts
357    /// are their own, and a QCode older than that folder does not see them at all.
358    #[must_use]
359    pub fn profiles(&self) -> Loaded<Vec<Profile>> {
360        let mut value = Vec::new();
361        let mut diagnostics = Vec::new();
362        let mut files = Vec::new();
363        for folder in [self.profiles_dir(), self.custom_profiles_dir()] {
364            match definition_files(&folder) {
365                Ok(found) => files.extend(found),
366                Err(problem) => diagnostics.push(problem),
367            }
368        }
369        files.sort();
370        for path in files {
371            match fs::read_to_string(&path) {
372                Ok(text) => {
373                    let loaded = Profile::parse(&path.to_string_lossy(), &text);
374                    diagnostics.extend(loaded.diagnostics);
375                    value.extend(loaded.profile);
376                }
377                Err(error) => diagnostics.push(blocked(&path, &error)),
378            }
379        }
380        Loaded { value, diagnostics }
381    }
382
383    /// The folder a custom profile's definition file is in.
384    #[must_use]
385    pub fn custom_profiles_dir(&self) -> PathBuf {
386        self.profiles_dir().join(CUSTOM)
387    }
388
389    /// The folder `profile`'s definition file is in, and its name: a ready-made template's is the
390    /// one every profile's file has always been in, and a custom one's is the folder of its own,
391    /// which a QCode that does not know it never reads.
392    #[must_use]
393    pub fn profile_file(&self, profile: &Profile) -> PathBuf {
394        let folder = match profile.template {
395            Template::Custom => self.custom_profiles_dir(),
396            _ => self.profiles_dir(),
397        };
398        folder.join(format!("{}.toml", profile.name))
399    }
400
401    /// Writes a profile's definition file atomically, making the store's folders first, and takes
402    /// away the file of the kind it is not: a profile has one definition, whichever folder holds
403    /// it, and two of them would list it twice.
404    ///
405    /// # Errors
406    ///
407    /// A diagnostic naming the folder or file that could not be written.
408    pub fn write_profile(&self, profile: &Profile) -> Result<(), Diagnostic> {
409        self.prepare()?;
410        let path = self.profile_file(profile);
411        // Made only when a custom profile is written, so a store without one looks as it always did.
412        if let Some(folder) = path.parent() {
413            fs::create_dir_all(folder).map_err(|error| blocked(folder, &error))?;
414        }
415        let stale = if profile.template == Template::Custom {
416            self.profiles_dir().join(format!("{}.toml", profile.name))
417        } else {
418            self.custom_profiles_dir().join(format!("{}.toml", profile.name))
419        };
420        let _ = fs::remove_file(&stale);
421        atomic_write(&path, profile.to_toml().as_bytes()).map_err(|error| blocked(&path, &error))
422    }
423
424    /// Reads one workspace folder, collecting everything that is wrong with it.
425    fn read_entry(&self, name: &str) -> WorkspaceEntry {
426        let dir = self.workspaces_dir().join(name);
427        let mut problems = Vec::new();
428        let id = match WorkspaceId::parse(name) {
429            Ok(id) => id,
430            Err(problem) => {
431                problems.push(Diagnostic::error(
432                    None,
433                    t!("workspaces.problem.not-an-id", dir = dir.display().to_string(), problem = problem.said()),
434                ));
435                return WorkspaceEntry { dir, file: None, problems };
436            }
437        };
438        let read = self.read_workspace(&id);
439        problems.extend(read.diagnostics);
440        let file = read.value.filter(|file| {
441            let matches = file.id == id;
442            if !matches {
443                problems.push(Diagnostic::error(
444                    None,
445                    t!("workspaces.problem.other-id", dir = dir.display().to_string(), id = file.id.as_str()),
446                ));
447            }
448            matches
449        });
450        let paths = file.as_ref().map_or_else(|| self.workspace_paths(&id), |file| self.paths_of(file));
451        let in_place = file.as_ref().is_some_and(|file| file.folder.is_some());
452        for missing in [&paths.code, &paths.assets, &paths.harness].into_iter().filter(|path| !path.is_dir()) {
453            // The person's own folder is theirs to bring back: an unplugged disk, a folder moved.
454            // It is said as theirs, so nobody reads it as something QCode will make again.
455            let key = if in_place && *missing == paths.code {
456                "workspaces.problem.theirs-missing"
457            } else {
458                "workspaces.problem.missing"
459            };
460            problems.push(Diagnostic::error(None, t!(key, path = missing.display().to_string())));
461        }
462        WorkspaceEntry { dir, file, problems }
463    }
464
465    /// The names of the folders below `Workspaces`, sorted.
466    fn folder_names(&self) -> Result<Vec<String>, Diagnostic> {
467        let dir = self.workspaces_dir();
468        let mut names = Vec::new();
469        for entry in fs::read_dir(&dir).map_err(|error| blocked(&dir, &error))? {
470            let entry = entry.map_err(|error| blocked(&dir, &error))?;
471            if entry.file_type().map_err(|error| blocked(&dir, &error))?.is_dir() {
472                names.push(entry.file_name().to_string_lossy().into_owned());
473            }
474        }
475        names.sort();
476        Ok(names)
477    }
478}
479
480/// Writes `file` as the `workspace.qcode` at `paths` atomically, and makes sure the workspace's
481/// folders — including one per profile it names — are there.
482///
483/// A workspace that works in the person's own folder gets no `Work/`, and that folder is never
484/// made: when it is gone, making an empty one in its place would hide that it is gone.
485fn write_workspace_at(paths: &WorkspacePaths, file: &WorkspaceFile) -> Result<(), Diagnostic> {
486    let mut dirs = vec![paths.assets.clone(), paths.harness.clone()];
487    if file.folder.is_none() {
488        dirs.push(paths.code.clone());
489    }
490    dirs.extend(file.profiles.iter().map(|profile| paths.harness_profile(&profile.name)));
491    for dir in dirs {
492        fs::create_dir_all(&dir).map_err(|error| blocked(&dir, &error))?;
493    }
494    atomic_write(&paths.file, file.to_toml().as_bytes()).map_err(|error| blocked(&paths.file, &error))
495}
496
497/// Records in the workspace at `paths` that it carries the profile `name`, added on `added`, and
498/// answers with the file as it now stands.
499///
500/// The file is read again first rather than written from what a screen remembers, so whatever
501/// was changed in it since the screen read it — by hand, or by another window — is kept. A
502/// profile the file already names is left as it is, with its own date.
503///
504/// Runs file work, so it belongs on a background thread.
505///
506/// # Errors
507///
508/// A diagnostic when the file cannot be read, names no usable workspace, or cannot be written.
509pub fn add_profile(paths: &WorkspacePaths, name: &str, added: Date) -> Result<WorkspaceFile, Diagnostic> {
510    let mut file = read_workspace_at(paths)?;
511    if !file.profiles.iter().any(|carried| carried.name == name) {
512        file.profiles.push(WorkspaceProfile { name: name.to_owned(), added: Some(added) });
513        write_workspace_at(paths, &file)?;
514    }
515    Ok(file)
516}
517
518/// Records in the workspace at `paths` that it no longer carries the profile `name`, because the
519/// profile was deleted, and answers with the file as it now stands.
520///
521/// Like [`add_profile`], the file is read again first, so a change made to it meanwhile is kept,
522/// and it is written only when it named the profile. The profile's folder under
523/// `Containers/Harness/` goes with the entry while it is empty; one that holds anything is left as
524/// it is.
525///
526/// # Errors
527///
528/// A `workspace.qcode` that cannot be read, names no workspace or cannot be written.
529pub fn remove_profile(paths: &WorkspacePaths, name: &str) -> Result<WorkspaceFile, Diagnostic> {
530    let mut file = read_workspace_at(paths)?;
531    let before = file.profiles.len();
532    file.profiles.retain(|carried| carried.name != name);
533    if file.profiles.len() != before {
534        write_workspace_at(paths, &file)?;
535    }
536    // Only an empty folder is removed, and one that will not go is no reason to call the rest a
537    // failure: the workspace file no longer names it.
538    let _ = fs::remove_dir(paths.harness_profile(name));
539    Ok(file)
540}
541
542/// Records in the workspace at `paths` that its backup leaves `keys` out, or takes them in again
543/// when `skip` is false, and answers with the file as it now stands.
544///
545/// Like [`add_profile`], the file is read again first, so a change made to it meanwhile is kept.
546/// A path already inside a folder that is left out is not added again, and leaving a folder out
547/// drops the entries inside it, which it covers from then on. Taking a path in again takes in
548/// only that path: a file inside a folder that stays left out stays out with it. The file is
549/// written only when the list changed.
550///
551/// # Errors
552///
553/// A key that is not a path inside the workspace, and a `workspace.qcode` that cannot be read, names
554/// no workspace or cannot be written.
555pub fn set_backup_skip(paths: &WorkspacePaths, keys: &[String], skip: bool) -> Result<WorkspaceFile, Diagnostic> {
556    let mut places = Vec::new();
557    for key in keys {
558        let place = Place::new(key).map_err(|bad| {
559            Diagnostic::error(
560                None,
561                t!("workspaces.file.not-inside", path = bad.path.as_str(), why = outside(bad.problem)),
562            )
563        })?;
564        places.push(place.as_str().to_owned());
565    }
566    let mut file = read_workspace_at(paths)?;
567    let before = file.backup_skip.clone();
568    let inside = |path: &str, folder: &str| path.strip_prefix(folder).is_some_and(|rest| rest.starts_with('/'));
569    for place in places {
570        if !skip {
571            file.backup_skip.retain(|known| *known != place);
572        } else if !file.backup_skip.iter().any(|known| *known == place || inside(&place, known)) {
573            file.backup_skip.retain(|known| !inside(known, &place));
574            file.backup_skip.push(place);
575        }
576    }
577    if file.backup_skip != before {
578        write_workspace_at(paths, &file)?;
579    }
580    Ok(file)
581}
582
583/// Records in the workspace at `paths` whether its backup takes `Assets/` too, and answers with the
584/// file as it now stands. The file is read again first, as for [`set_backup_skip`], and written
585/// only when the choice changed.
586///
587/// # Errors
588///
589/// A `workspace.qcode` that cannot be read, names no workspace or cannot be written.
590pub fn set_backup_assets(paths: &WorkspacePaths, assets: bool) -> Result<WorkspaceFile, Diagnostic> {
591    let mut file = read_workspace_at(paths)?;
592    if file.backup_assets != assets {
593        file.backup_assets = assets;
594        write_workspace_at(paths, &file)?;
595    }
596    Ok(file)
597}
598
599/// The workspace file at `paths` as it is on disk now.
600fn read_workspace_at(paths: &WorkspacePaths) -> Result<WorkspaceFile, Diagnostic> {
601    let text = fs::read_to_string(&paths.file).map_err(|error| blocked(&paths.file, &error))?;
602    let read = WorkspaceFile::parse(WORKSPACE_FILE, &text);
603    read.value.ok_or_else(|| {
604        let reason = read.diagnostics.first().map_or_else(|| t!("workspaces.file.unusable"), ToString::to_string);
605        Diagnostic::error(None, format!("{}: {reason}", paths.file.display()))
606    })
607}
608
609/// The diagnostic for a path the file system would not let this program use.
610/// The definition files in `folder`; none for a folder that is not there, which means no profiles
611/// of its kind.
612fn definition_files(folder: &Path) -> Result<Vec<PathBuf>, Diagnostic> {
613    let entries = match fs::read_dir(folder) {
614        Ok(entries) => entries,
615        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
616        Err(error) => return Err(blocked(folder, &error)),
617    };
618    Ok(entries
619        .flatten()
620        .map(|entry| entry.path())
621        .filter(|path| path.extension().is_some_and(|extension| extension == "toml"))
622        .collect())
623}
624
625fn blocked(path: &Path, error: &io::Error) -> Diagnostic {
626    Diagnostic::error(None, format!("{}: {error}", path.display()))
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use crate::profile::{AccountKind, Extra, HarnessKind, MountAccess, NetworkMode, SafeName, Template};
633    use std::sync::atomic::{AtomicU32, Ordering};
634
635    /// A directory of this test's own, removed first so a crashed run cannot poison the next.
636    fn scratch(name: &str) -> PathBuf {
637        static COUNT: AtomicU32 = AtomicU32::new(0);
638        let unique = COUNT.fetch_add(1, Ordering::Relaxed);
639        let dir = std::env::temp_dir().join(format!("qcode-store-{name}-{}-{unique}", std::process::id()));
640        let _ = fs::remove_dir_all(&dir);
641        dir
642    }
643
644    fn entries(dir: &Path) -> Vec<String> {
645        let mut names: Vec<String> = fs::read_dir(dir)
646            .expect("the directory is there")
647            .map(|entry| entry.expect("a readable entry").file_name().to_string_lossy().into_owned())
648            .collect();
649        names.sort();
650        names
651    }
652
653    fn today() -> Date {
654        Date::new(2026, 9, 17).expect("a real date")
655    }
656
657    fn profile(name: &str) -> Profile {
658        Profile {
659            name: SafeName::parse(name).expect("the name is safe"),
660            harness: HarnessKind::ClaudeCode,
661            template: Template::Recommended,
662            account: AccountKind::Subscription,
663            provider: None,
664            assets: MountAccess::ReadOnly,
665            network: NetworkMode::Full,
666            without: Vec::new(),
667            os: crate::base::Os::Debian,
668        }
669    }
670
671    #[test]
672    fn a_store_without_a_profiles_folder_simply_has_no_profiles() {
673        let loaded = Store::new(scratch("no-profiles")).profiles();
674        assert!(loaded.value.is_empty());
675        assert!(loaded.is_clean(), "a folder that was never made is not a problem: {:?}", loaded.diagnostics);
676    }
677
678    #[test]
679    fn a_written_profile_reads_back_as_the_same_profile() {
680        let store = Store::new(scratch("profile-roundtrip"));
681        let written = profile("claude-sub");
682        store.write_profile(&written).expect("the store can be written");
683        let loaded = store.profiles();
684        assert!(loaded.is_clean(), "{:?}", loaded.diagnostics);
685        assert_eq!(loaded.value, vec![written]);
686        let _ = fs::remove_dir_all(store.root());
687    }
688
689    #[test]
690    fn a_broken_definition_is_reported_and_the_readable_ones_are_kept() {
691        let store = Store::new(scratch("profile-broken"));
692        store.write_profile(&profile("claude-sub")).expect("the store can be written");
693        fs::write(store.profiles_dir().join("half.toml"), "harness = \"claude-code\"\n").expect("the folder exists");
694        let loaded = store.profiles();
695        assert_eq!(loaded.value.len(), 1, "the readable profile is kept: {:?}", loaded.value);
696        assert!(!loaded.is_clean(), "the broken one is reported");
697        let _ = fs::remove_dir_all(store.root());
698    }
699
700    #[test]
701    fn a_custom_profile_is_written_where_a_qcode_from_before_them_does_not_look() {
702        let store = Store::new(scratch("custom-folder"));
703        let mut custom = profile("claude-own");
704        custom.template = Template::Custom;
705        custom.without = vec![Extra::Graphify];
706        store.write_profile(&custom).expect("the store can be written");
707        let file = store.profile_file(&custom);
708        assert_eq!(file, store.custom_profiles_dir().join("claude-own.toml"));
709        assert!(file.is_file(), "the file is there");
710        let loaded = store.profiles();
711        assert!(loaded.is_clean(), "{:?}", loaded.diagnostics);
712        assert_eq!(loaded.value, vec![custom.clone()]);
713
714        // A ready-made set's file is where every profile's file has always been, and a change of
715        // kind takes the other one away, so a profile is never listed twice.
716        let mut ready = profile("claude-own");
717        ready.template = Template::High;
718        store.write_profile(&ready).expect("the store can be written");
719        assert!(store.profile_file(&ready).is_file(), "in the folder every profile's file is in");
720        assert!(!file.exists(), "and the custom one is gone: {:?}", store.profiles());
721        let loaded = store.profiles();
722        assert!(loaded.is_clean(), "{:?}", loaded.diagnostics);
723        assert_eq!(loaded.value, vec![ready.clone()], "listed once, under the set it is now");
724        let _ = fs::remove_dir_all(store.root());
725    }
726
727    #[test]
728    fn preparing_a_store_creates_the_two_folders_of_the_design_and_no_more() {
729        let root = scratch("prepare");
730        let store = Store::new(&root);
731        store.prepare().expect("a writable temporary directory");
732        assert_eq!(entries(&root), ["Profiles", "Workspaces"]);
733        store.prepare().expect("preparing again changes nothing");
734        assert_eq!(entries(&root), ["Profiles", "Workspaces"]);
735        fs::remove_dir_all(&root).expect("cleaned up");
736    }
737
738    #[test]
739    fn a_new_workspace_gets_the_tree_of_the_design_without_backup_or_mcp() {
740        let root = scratch("create");
741        let store = Store::new(&root);
742        store.prepare().expect("writable");
743        let file = store.create_workspace("Görünen ad", today()).expect("a free name");
744        assert_eq!(file.id.as_str(), "gorunen-ad");
745
746        let paths = store.workspace_paths(&file.id);
747        assert_eq!(entries(&paths.root), ["Assets", "Containers", "Work", "workspace.qcode"]);
748        assert_eq!(entries(&paths.root.join("Containers")), ["Harness"]);
749        assert!(paths.harness.join("").is_dir());
750        assert_eq!(entries(&paths.harness), [] as [String; 0]);
751        assert_eq!(paths.backup(), paths.root.join("Backup"), "named, but left for the first backup to make");
752
753        let read = store.read_workspace(&file.id);
754        assert!(read.is_clean(), "{:?}", read.diagnostics);
755        assert_eq!(read.value, Some(file));
756        fs::remove_dir_all(&root).expect("cleaned up");
757    }
758
759    #[test]
760    fn a_name_that_is_already_taken_is_reported_and_nothing_is_overwritten() {
761        let root = scratch("clash");
762        let store = Store::new(&root);
763        store.prepare().expect("writable");
764        let first = store.create_workspace("Proje", today()).expect("a free name");
765        // Different display name, same identifier, and on a case-insensitive file system the
766        // same folder: the caller decides what to do, this layer never invents `proje-2`.
767        match store.create_workspace("PROJE", today()) {
768            Err(NewWorkspaceError::Taken(id)) => assert_eq!(id, first.id),
769            other => panic!("expected a clash, got {other:?}"),
770        }
771        assert_eq!(entries(&store.workspaces_dir()), ["proje"]);
772        fs::remove_dir_all(&root).expect("cleaned up");
773    }
774
775    #[test]
776    fn a_name_with_nothing_usable_in_it_never_reaches_the_disk() {
777        let root = scratch("empty-name");
778        let store = Store::new(&root);
779        store.prepare().expect("writable");
780        assert!(matches!(
781            store.create_workspace("...", today()),
782            Err(NewWorkspaceError::Name(WorkspaceIdError::Empty))
783        ));
784        assert_eq!(entries(&store.workspaces_dir()), [] as [String; 0]);
785        fs::remove_dir_all(&root).expect("cleaned up");
786    }
787
788    #[test]
789    fn a_broken_workspace_appears_in_the_list_with_its_diagnostic() {
790        let root = scratch("list");
791        let store = Store::new(&root);
792        store.prepare().expect("writable");
793        store.create_workspace("Saglam", today()).expect("a free name");
794
795        // Half a workspace: a folder, no workspace file and none of the subfolders.
796        fs::create_dir_all(store.workspaces_dir().join("yarim")).expect("writable");
797        // A workspace file that cannot be read at all.
798        let broken = store.workspaces_dir().join("bozuk");
799        fs::create_dir_all(&broken).expect("writable");
800        fs::write(broken.join("workspace.qcode"), "id = \n").expect("writable");
801        // A folder whose name no workspace could ever have.
802        fs::create_dir_all(store.workspaces_dir().join("Büyük Harf")).expect("writable");
803
804        let listed = store.workspaces();
805        let names: Vec<String> = listed
806            .value
807            .iter()
808            .map(|entry| entry.dir.file_name().unwrap_or_default().to_string_lossy().into_owned())
809            .collect();
810        assert_eq!(names, ["Büyük Harf", "bozuk", "saglam", "yarim"]);
811        assert_eq!(listed.value.iter().filter(|entry| entry.is_broken()).count(), 3);
812        assert_eq!(listed.value[2].file.as_ref().map(|file| file.name.clone()), Some("Saglam".to_owned()));
813        assert!(listed.value[0].file.is_none(), "an illegal folder name cannot name a workspace");
814        assert!(!listed.diagnostics.is_empty());
815        assert!(listed.value.iter().filter(|entry| entry.is_broken()).all(|entry| !entry.problems.is_empty()));
816        fs::remove_dir_all(&root).expect("cleaned up");
817    }
818
819    #[test]
820    fn a_workspace_whose_file_names_another_workspace_is_broken() {
821        let root = scratch("mismatch");
822        let store = Store::new(&root);
823        store.prepare().expect("writable");
824        let file = store.create_workspace("Proje", today()).expect("a free name");
825        let paths = store.workspace_paths(&file.id);
826        fs::write(&paths.file, "id = \"baska\"\nname = \"Proje\"\n").expect("writable");
827        let listed = store.workspaces();
828        assert!(listed.value[0].is_broken());
829        fs::remove_dir_all(&root).expect("cleaned up");
830    }
831
832    #[test]
833    fn a_broken_workspace_is_explained_in_the_active_language() {
834        let root = scratch("turkish");
835        let store = Store::new(&root);
836        store.prepare().expect("writable");
837        // A folder whose name no workspace could ever have.
838        fs::create_dir_all(store.workspaces_dir().join("Büyük Harf")).expect("writable");
839        // A workspace whose file names another one.
840        let other = store.create_workspace("Proje", today()).expect("a free name");
841        fs::write(&store.workspace_paths(&other.id).file, "id = \"baska\"\nname = \"Proje\"\n").expect("writable");
842        // A workspace that lost its Assets, and one whose own folder, where it works, is gone.
843        let lost = store.create_workspace("Kayip", today()).expect("a free name");
844        fs::remove_dir_all(store.workspace_paths(&lost.id).assets).expect("removable");
845        let gone = root.join("gitti");
846        store.create_workspace_in("Yerinde", today(), Some(gone.clone())).expect("a free name");
847
848        let listed = crate::ui::in_language("tr", || store.workspaces());
849        let said: Vec<String> = listed
850            .value
851            .iter()
852            .flat_map(|entry| entry.problems.iter().map(|problem| problem.message.clone()))
853            .collect();
854        for expected in [
855            "Büyük Harf: bu klasör adı bir çalışma alanı kimliği değil: `B` klasör adında olamaz; adın 1. karakteri.",
856            "proje: çalışma alanı dosyası bu çalışma alanına `baska` diyor",
857            "Assets: çalışma alanının bu klasörü eksik",
858            "gitti: bu çalışma alanının çalıştığı klasör yerinde yok; QCode onu oluşturmaz",
859        ] {
860            assert!(said.iter().any(|message| message.ends_with(expected)), "`{expected}` in {said:#?}");
861        }
862        fs::remove_dir_all(&root).expect("cleaned up");
863    }
864
865    #[test]
866    fn a_path_left_out_of_the_backup_that_is_not_inside_is_refused_in_the_active_language() {
867        let root = scratch("turkish-skip");
868        let store = Store::new(&root);
869        store.prepare().expect("writable");
870        let file = store.create_workspace("Proje", today()).expect("a free name");
871        let keys = ["../disari".to_owned()];
872        let refused = crate::ui::in_language("tr", || set_backup_skip(&store.workspace_paths(&file.id), &keys, true))
873            .expect_err("a path that leaves the workspace");
874        assert_eq!(
875            refused.message,
876            "`../disari` çalışma alanının içinde bir yol değil: bir `.` ya da `..` parçası çalışma alanının dışına çıkıyor"
877        );
878        fs::remove_dir_all(&root).expect("cleaned up");
879    }
880
881    #[test]
882    fn listing_an_empty_or_missing_store_is_not_an_error() {
883        let root = scratch("missing");
884        let store = Store::new(&root);
885        let listed = store.workspaces();
886        assert!(listed.value.is_empty());
887        assert_eq!(listed.diagnostics.len(), 1, "a missing store is worth saying out loud");
888
889        store.prepare().expect("writable");
890        let listed = store.workspaces();
891        assert!(listed.value.is_empty() && listed.is_clean());
892        fs::remove_dir_all(&root).expect("cleaned up");
893    }
894
895    #[test]
896    fn writing_a_workspace_creates_the_folder_of_every_profile_it_names() {
897        let root = scratch("profiles");
898        let store = Store::new(&root);
899        store.prepare().expect("writable");
900        let mut file = store.create_workspace("Proje", today()).expect("a free name");
901        file.profiles.push(WorkspaceProfile { name: "claude-sub".to_owned(), added: Some(today()) });
902        store.write_workspace(&file).expect("writable");
903        assert_eq!(entries(&store.workspace_paths(&file.id).harness), ["claude-sub"]);
904        assert_eq!(store.read_workspace(&file.id).value, Some(file));
905        fs::remove_dir_all(&root).expect("cleaned up");
906    }
907
908    #[test]
909    fn adding_a_profile_records_it_once_and_keeps_what_the_file_already_says() {
910        let root = scratch("add-profile");
911        let store = Store::new(&root);
912        let mut file = store.create_workspace("Proje", today()).expect("a free name");
913        file.profiles.push(WorkspaceProfile { name: "opencode".to_owned(), added: Some(today()) });
914        store.write_workspace(&file).expect("writable");
915        let paths = store.workspace_paths(&file.id);
916        let later = Date::new(2026, 9, 18).expect("a real date");
917
918        let added = add_profile(&paths, "claude-sub", later).expect("writable");
919        let names: Vec<&str> = added.profiles.iter().map(|profile| profile.name.as_str()).collect();
920        assert_eq!(names, ["opencode", "claude-sub"], "the profile the file named by hand is kept");
921        assert_eq!(store.read_workspace(&file.id).value, Some(added.clone()), "and it is on disk");
922        assert_eq!(entries(&paths.harness), ["claude-sub", "opencode"], "with a folder of its own");
923
924        // Adding it again changes nothing, not even the day it was first added.
925        let again = add_profile(&paths, "claude-sub", Date::new(2026, 9, 19).expect("a real date")).expect("fine");
926        assert_eq!(again, added);
927        fs::remove_dir_all(&root).expect("cleaned up");
928    }
929
930    #[test]
931    fn what_the_backup_leaves_out_is_written_into_the_file_as_it_stands_now() {
932        let root = scratch("skip");
933        let store = Store::new(&root);
934        let file = store.create_workspace("Proje", today()).expect("writable");
935        let paths = store.workspace_paths(&file.id);
936        let keys = |keys: &[&str]| keys.iter().map(|key| (*key).to_owned()).collect::<Vec<_>>();
937
938        let out = set_backup_skip(&paths, &keys(&["data/raw", "out"]), true).expect("writable");
939        assert_eq!(out.backup_skip, ["data/raw", "out"]);
940        // Changed by hand meanwhile: the hand's change is kept.
941        add_profile(&paths, "claude-sub", today()).expect("writable");
942        let out = set_backup_skip(&paths, &keys(&["data", "out/big"]), true).expect("writable");
943        assert_eq!(out.backup_skip, ["out", "data"], "the folder covers what was inside it, and what `out` covers");
944        assert_eq!(out.profiles.len(), 1, "the profile added meanwhile is still there");
945        assert_eq!(store.read_workspace(&file.id).value, Some(out.clone()), "and it is on disk");
946
947        let back = set_backup_skip(&paths, &keys(&["out", "data/raw"]), false).expect("writable");
948        assert_eq!(back.backup_skip, ["data"], "a path inside a folder still left out stays out with it");
949        assert!(set_backup_skip(&paths, &keys(&["../elsewhere"]), true).is_err(), "only paths inside the workspace");
950        assert_eq!(store.read_workspace(&file.id).value, Some(back));
951        fs::remove_dir_all(&root).expect("cleaned up");
952    }
953
954    #[test]
955    fn the_assets_switch_is_written_into_the_file_as_it_stands_now() {
956        let root = scratch("assets");
957        let store = Store::new(&root);
958        let file = store.create_workspace("Proje", today()).expect("writable");
959        let paths = store.workspace_paths(&file.id);
960        let on = set_backup_assets(&paths, true).expect("writable");
961        assert!(on.backup_assets);
962        // Changed by hand meanwhile: the hand's change is kept.
963        set_backup_skip(&paths, &["data".to_owned()], true).expect("writable");
964        let off = set_backup_assets(&paths, false).expect("writable");
965        assert!(!off.backup_assets);
966        assert_eq!(off.backup_skip, ["data"]);
967        assert_eq!(store.read_workspace(&file.id).value, Some(off));
968        assert!(!fs::read_to_string(&paths.file).expect("the file").contains("assets"), "off is not written");
969        let missing = store.workspace_paths(&WorkspaceId::parse("yok").expect("an id"));
970        assert!(set_backup_assets(&missing, true).is_err());
971        fs::remove_dir_all(&root).expect("cleaned up");
972    }
973
974    #[test]
975    fn adding_a_profile_to_a_workspace_that_is_not_there_is_a_diagnostic() {
976        let root = scratch("add-missing");
977        let store = Store::new(&root);
978        let paths = store.workspace_paths(&WorkspaceId::parse("yok").expect("an id"));
979        let error = add_profile(&paths, "claude-sub", today()).expect_err("nothing to add to");
980        assert!(error.message.contains("workspace.qcode"), "{}", error.message);
981        assert!(!paths.file.exists(), "no file is made up for a workspace that is not there");
982    }
983
984    #[cfg(unix)]
985    #[test]
986    fn a_store_that_cannot_be_written_is_a_diagnostic_and_not_a_panic() {
987        use std::os::unix::fs::PermissionsExt;
988
989        let root = scratch("read-only");
990        fs::create_dir_all(&root).expect("writable");
991        let inner = root.join("QCode");
992        fs::set_permissions(&root, fs::Permissions::from_mode(0o500)).expect("a mode this user may set");
993
994        let store = Store::new(&inner);
995        let blocked = store.prepare().expect_err("the parent forbids it");
996        assert!(blocked.to_string().contains(&inner.display().to_string()), "{blocked}");
997
998        let listed = store.workspaces();
999        assert!(listed.value.is_empty() && !listed.is_clean());
1000
1001        assert!(matches!(store.create_workspace("Proje", today()), Err(NewWorkspaceError::Blocked(_))));
1002
1003        fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).expect("a mode this user may set");
1004        fs::remove_dir_all(&root).expect("cleaned up");
1005    }
1006}