1use 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
40const WORKSPACE_FILE: &str = "workspace.qcode";
42
43const PROFILES: &str = "Profiles";
45
46const CUSTOM: &str = "custom";
48
49const WORKSPACES: &str = "Workspaces";
51
52const CODE: &str = "Work";
55
56const LEGACY: [(&str, &str); 3] = [("project.qcode", WORKSPACE_FILE), ("Project", CODE), ("Code", CODE)];
61
62const LEGACY_WORKSPACES: &str = "Projects";
65
66#[derive(Debug)]
68pub enum NewWorkspaceError {
69 Name(WorkspaceIdError),
71 Taken(WorkspaceId),
74 Blocked(Diagnostic),
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct WorkspacePaths {
81 pub root: PathBuf,
83 pub file: PathBuf,
85 pub code: PathBuf,
87 pub assets: PathBuf,
89 pub harness: PathBuf,
91}
92
93impl WorkspacePaths {
94 #[must_use]
96 pub fn harness_profile(&self, profile: &str) -> PathBuf {
97 self.harness.join(profile)
98 }
99
100 #[must_use]
107 pub fn mcp(&self) -> PathBuf {
108 self.root.join("Containers").join("MCP")
109 }
110
111 #[must_use]
117 pub fn browser(&self) -> PathBuf {
118 self.root.join("Containers").join("Browser")
119 }
120
121 #[must_use]
126 pub fn backup(&self) -> PathBuf {
127 self.root.join("Backup")
128 }
129}
130
131#[derive(Debug, Clone, PartialEq)]
133pub struct WorkspaceEntry {
134 pub dir: PathBuf,
136 pub file: Option<WorkspaceFile>,
138 pub problems: Vec<Diagnostic>,
140}
141
142impl WorkspaceEntry {
143 #[must_use]
146 pub fn is_broken(&self) -> bool {
147 !self.problems.is_empty()
148 }
149}
150
151fn 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#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct Store {
171 root: PathBuf,
172}
173
174impl Store {
175 #[must_use]
177 pub fn new(root: impl Into<PathBuf>) -> Self {
178 Self { root: root.into() }
179 }
180
181 #[must_use]
183 pub fn root(&self) -> &Path {
184 &self.root
185 }
186
187 #[must_use]
189 pub fn profiles_dir(&self) -> PathBuf {
190 self.root.join(PROFILES)
191 }
192
193 #[must_use]
195 pub fn workspaces_dir(&self) -> PathBuf {
196 self.root.join(WORKSPACES)
197 }
198
199 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 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 #[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 #[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 pub fn create_workspace(&self, name: &str, created: Date) -> Result<WorkspaceFile, NewWorkspaceError> {
278 self.create_workspace_in(name, created, None)
279 }
280
281 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 pub fn write_workspace(&self, file: &WorkspaceFile) -> Result<(), Diagnostic> {
316 write_workspace_at(&self.workspace_paths(&file.id), file)
317 }
318
319 #[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 #[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 #[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 #[must_use]
385 pub fn custom_profiles_dir(&self) -> PathBuf {
386 self.profiles_dir().join(CUSTOM)
387 }
388
389 #[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 pub fn write_profile(&self, profile: &Profile) -> Result<(), Diagnostic> {
409 self.prepare()?;
410 let path = self.profile_file(profile);
411 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 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 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 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
480fn 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
497pub 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
518pub 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 let _ = fs::remove_dir(paths.harness_profile(name));
539 Ok(file)
540}
541
542pub 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
583pub 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
599fn 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
609fn 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 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 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 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 fs::create_dir_all(store.workspaces_dir().join("yarim")).expect("writable");
797 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 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 fs::create_dir_all(store.workspaces_dir().join("Büyük Harf")).expect("writable");
839 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 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 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 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 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}