wsb 0.253.6349

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
use crate::prelude::*;
use std::fs;

/// A single project registered for wstemplate scanning.
///
/// Each project has exactly one entry defining:
/// - `alias`: the identifier used in `{{ projects.ALIAS.* }}` template vars
/// - `root`: the directory tree to scan for `.wstemplate` files on `wsb update`
///
/// All other projects referenced in a template are resolved dynamically by
/// scanning the `root` directory for `.wsb/state.json` files.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WstemplateEntry {
    pub root: PathBuf,
    pub alias: String,
}

/// Per-project workspace configuration, persisted as `.wsb/state.json`.
///
/// Each project has at most one [`WstemplateEntry`] defining its alias and
/// scan root. Cross-project references are resolved dynamically by scanning
/// the root for sibling `.wsb/state.json` files — no explicit cross-project
/// entries are needed. Loading a state file with more than one entry is a
/// hard error.
///
/// In memory every path is absolute. On disk nothing names where the project
/// is (see [`StateFile`]): the project's root is the directory holding its
/// `.wsb`, and its scan root is stored relative to that.
#[derive(Debug)]
pub struct WorkspaceState {
    pub version: u32,
    pub version_suffix: Option<String>,
    pub project_root: PathBuf,
    pub project_name: Option<String>,
    pub tools: HashMap<String, serde_json::Value>,
    pub wstemplate_entries: Vec<WstemplateEntry>,
    /// Submodules that every fleet git command should skip, given as paths from
    /// this project root (`ext/gtest`) or patterns over them (`ext/*`).
    ///
    /// Ignoring a submodule also prunes everything nested inside it. Omitted from
    /// the file entirely when empty, so it only appears once you set it.
    pub ignored_submodules: Vec<String>,
    /// Set to true after the shell completion hint has been shown once.
    pub completion_hint_shown: bool,
}

/// What `.wsb/state.json` holds: nothing that says where the project is.
///
/// A project is wherever its `.wsb` is — moved, copied into a snapshot,
/// staged on a guest. The file once stored the project's absolute path and its
/// scan root's, and every copy of the tree then rendered against the place it
/// was copied FROM: a ship working in its snapshot found no project registered
/// there and stopped at its first regeneration. So the scan root is stored
/// relative to the project, and the project's own place is not stored at all.
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StateFile {
    version: u32,
    #[serde(default)]
    version_suffix: Option<String>,
    project_name: Option<String>,
    tools: HashMap<String, serde_json::Value>,
    #[serde(default)]
    wstemplate_entries: Vec<StoredEntry>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    ignored_submodules: Vec<String>,
    #[serde(default)]
    completion_hint_shown: bool,
}

/// A [`WstemplateEntry`] as stored: its root relative to the project.
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredEntry {
    alias: String,
    root: PathBuf,
}

impl Default for WorkspaceState {
    fn default() -> Self {
        Self {
            version: 1,
            version_suffix: None,
            project_root: PathBuf::new(),
            project_name: None,
            tools: HashMap::new(),
            wstemplate_entries: Vec::new(),
            ignored_submodules: Vec::new(),
            completion_hint_shown: false,
        }
    }
}

impl WorkspaceState {
    /// Initialize workspace state in a project directory.
    /// If `name` is provided it is used as the project name, otherwise the
    /// directory name of `project_root` is used.
    pub fn initialize(project_root: &Path, name: Option<&str>) -> Result<Self> {
        let workspace_dir = project_root.join(".wsb");
        fs::create_dir_all(&workspace_dir).context("Failed to create .wsb directory")?;

        // Create subdirectories
        fs::create_dir_all(workspace_dir.join("templates"))
            .context("Failed to create templates directory")?;
        // Logs are NOT created here. They live under the declared logs root
        // (`.wsb/disposable/`), which is one directory for everything a run
        // throws away — and creating a second, empty `.wsb/logs` beside it is
        // how the two spellings got into the tree in the first place.

        let mut state = Self::default();
        state.project_root = project_root.to_path_buf();
        state.project_name = match name {
            Some(n) => Some(n.to_string()),
            None => project_root
                .file_name()
                .and_then(|n| n.to_str())
                .map(|s| s.to_string()),
        };

        state.save(project_root)?;
        Ok(state)
    }

    /// Load workspace state from project directory.
    /// Fails with a descriptive error if the workspace is not initialized.
    pub fn load(project_root: &Path) -> Result<Self> {
        let state_file = project_root.join(".wsb").join("state.json");

        if !state_file.exists() {
            anyhow::bail!(
                "Not in a wsb workspace. Run 'wsb init' to initialize one, or cd into a directory that has one."
            );
        }

        let content =
            fs::read_to_string(&state_file).context("Failed to read workspace state file")?;
        let value: serde_json::Value = serde_json::from_str(&content)
            .with_context(|| format!("{} is not JSON", state_file.display()))?;
        if value.get("project_root").is_some() {
            anyhow::bail!(
                "{} names the project's place (`project_root`), which the file no longer \
                 records: a project is wherever its .wsb is. Rewrite it with \
                 `wsb workspace relocate-state`.",
                state_file.display()
            );
        }
        let stored: StateFile = serde_json::from_value(value)
            .with_context(|| format!("Failed to parse workspace state file {}", state_file.display()))?;

        // Enforce single-entry wstemplate model.
        // Each project has at most one entry (its own alias + scan root).
        // All other projects are discovered dynamically by scanning the root.
        if stored.wstemplate_entries.len() > 1 {
            anyhow::bail!(
                "state.json at {} has {} wstemplate entries, expected at most 1. \
                 Each project should have exactly one entry (its own alias + scan root). \
                 Remove extra entries from {}",
                state_file.display(),
                stored.wstemplate_entries.len(),
                state_file.display()
            );
        }
        let mut wstemplate_entries = Vec::new();
        for entry in stored.wstemplate_entries {
            if entry.root.is_absolute() {
                anyhow::bail!(
                    "{}: the scan root of '{}' is {}, an absolute path — it is stored relative \
                     to the project, so a copy of the tree scans itself. Rewrite it with \
                     `wsb workspace relocate-state`.",
                    state_file.display(),
                    entry.alias,
                    entry.root.display()
                );
            }
            wstemplate_entries.push(WstemplateEntry {
                alias: entry.alias,
                root: lexically_joined(project_root, &entry.root),
            });
        }

        Ok(Self {
            version: stored.version,
            version_suffix: stored.version_suffix,
            project_root: project_root.to_path_buf(),
            project_name: stored.project_name,
            tools: stored.tools,
            wstemplate_entries,
            ignored_submodules: stored.ignored_submodules,
            completion_hint_shown: stored.completion_hint_shown,
        })
    }

    /// Save workspace state to project directory
    pub fn save(&self, project_root: &Path) -> Result<()> {
        let workspace_dir = project_root.join(".wsb");
        fs::create_dir_all(&workspace_dir).context("Failed to create .wsb directory")?;

        // Relative between PLACES, not spellings: one path canonical and the
        // other not (`/var` is `/private/var` on a mac) would store a climb
        // out through the wrong name. Both exist when a state is saved.
        let place = |path: &Path| crate::platform::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
        let project_place = place(project_root);
        let mut wstemplate_entries = Vec::new();
        for entry in &self.wstemplate_entries {
            wstemplate_entries.push(StoredEntry {
                alias: entry.alias.clone(),
                root: relative_to(&place(&entry.root), &project_place).with_context(|| {
                    format!("storing the scan root of '{}' relative to {}", entry.alias, project_root.display())
                })?,
            });
        }
        let stored = StateFile {
            version: self.version,
            version_suffix: self.version_suffix.clone(),
            project_name: self.project_name.clone(),
            tools: self.tools.clone(),
            wstemplate_entries,
            ignored_submodules: self.ignored_submodules.clone(),
            completion_hint_shown: self.completion_hint_shown,
        };

        let state_file = workspace_dir.join("state.json");
        let content =
            serde_json::to_string_pretty(&stored).context("Failed to serialize workspace state")?;

        fs::write(&state_file, content).context("Failed to write workspace state file")?;

        Ok(())
    }

    pub fn version_suffix(&self) -> Option<&str> {
        self.version_suffix
            .as_deref()
            .filter(|suffix| !suffix.is_empty())
    }

    pub fn effective_version_suffix(&self, override_suffix: Option<&str>) -> Option<String> {
        override_suffix
            .filter(|suffix| !suffix.is_empty())
            .map(str::to_owned)
            .or_else(|| self.version_suffix().map(str::to_owned))
    }

    pub fn format_version(
        &self,
        version_info: &crate::state::VersionInfo,
        override_suffix: Option<&str>,
    ) -> String {
        let base_version = format!(
            "{}.{}.{}",
            version_info
                .major_version
                .strip_prefix('v')
                .unwrap_or(&version_info.major_version),
            version_info.minor_version,
            version_info.patch_version
        );

        match self.effective_version_suffix(override_suffix) {
            Some(suffix) => format!("{base_version}-{suffix}"),
            None => base_version,
        }
    }

    /// Get workspace directory path
    pub fn workspace_dir(&self) -> PathBuf {
        self.project_root.join(".wsb")
    }

    /// Get tool-specific directory
    pub fn tool_dir(&self, tool_name: &str) -> PathBuf {
        self.workspace_dir().join(tool_name)
    }

    /// Get or create tool-specific configuration
    pub fn get_tool_config<T>(&self, tool_name: &str) -> Option<T>
    where
        T: for<'de> Deserialize<'de>,
    {
        self.tools
            .get(tool_name)
            .and_then(|value| serde_json::from_value(value.clone()).ok())
    }

    /// Set tool-specific configuration
    pub fn set_tool_config<T>(&mut self, tool_name: &str, config: &T) -> Result<()>
    where
        T: Serialize,
    {
        let value = serde_json::to_value(config).context("Failed to serialize tool config")?;

        self.tools.insert(tool_name.to_string(), value);
        Ok(())
    }

    /// Return the project's wstemplate entry, if configured.
    ///
    /// Each project has at most one entry: its own alias and scan root.
    /// All other projects are discovered dynamically by scanning the root.
    pub fn wstemplate_entry(&self) -> Option<&WstemplateEntry> {
        self.wstemplate_entries.first()
    }

    /// Return the effective wstemplate entry for this project.
    ///
    /// If no entry is explicitly configured, default to scanning this project's
    /// own root using a deterministic alias derived from the project directory.
    /// Missing configuration must not disable `.wstemplate` rendering.
    pub fn effective_wstemplate_entry(&self) -> WstemplateEntry {
        self.wstemplate_entry()
            .cloned()
            .unwrap_or_else(|| WstemplateEntry {
                alias: crate::state::wstemplate::WstemplateEngine::derive_alias(&self.project_root),
                root: self.project_root.clone(),
            })
    }

    /// Set the project's wstemplate entry, replacing any existing entry.
    ///
    /// Each project has exactly ONE entry: its own alias and scan root.
    pub fn set_wstemplate_entry(&mut self, entry: WstemplateEntry) {
        self.wstemplate_entries = vec![entry];
    }

    /// Remove the project's wstemplate entry entirely.
    pub fn clear_wstemplate_entry(&mut self) {
        self.wstemplate_entries.clear();
    }
}

/// `relative` resolved against `base` without touching the disk: `..` steps up
/// a component, `.` is nothing. A scan root `../..` from
/// `ws/capdag/capdag-rs` is `ws`, whether or not anything there is a link.
fn lexically_joined(base: &Path, relative: &Path) -> PathBuf {
    let mut joined = base.to_path_buf();
    for component in relative.components() {
        match component {
            std::path::Component::ParentDir => {
                joined.pop();
            }
            std::path::Component::CurDir => {}
            other => joined.push(other.as_os_str()),
        }
    }
    joined
}

/// `path` relative to `base`, both absolute: `..` for every component of
/// `base` below their common ancestor, then the rest of `path`.
pub fn relative_to(path: &Path, base: &Path) -> Result<PathBuf> {
    if !path.is_absolute() || !base.is_absolute() {
        anyhow::bail!(
            "{} and {} must both be absolute to store one relative to the other",
            path.display(),
            base.display()
        );
    }
    let path: Vec<_> = path.components().collect();
    let base: Vec<_> = base.components().collect();
    let common = path.iter().zip(&base).take_while(|(a, b)| a == b).count();
    let mut relative = PathBuf::new();
    for _ in common..base.len() {
        relative.push("..");
    }
    for component in &path[common..] {
        relative.push(component.as_os_str());
    }
    if relative.as_os_str().is_empty() {
        relative.push(".");
    }
    Ok(relative)
}

/// Rewrite every `.wsb/state.json` under `root` that names its project's place
/// into the shape [`WorkspaceState::load`] reads: `project_root` dropped, the
/// scan root stored relative to the project it was stored for. The paths it
/// names relative to the old place keep their meaning at the new one. Returns
/// the files rewritten.
pub fn relocate_state_files(root: &Path) -> Result<Vec<PathBuf>> {
    let mut candidates: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
    for entry in walkdir::WalkDir::new(root)
        .follow_links(false)
        .into_iter()
        .filter_entry(|e| {
            if e.depth() == 0 {
                return true;
            }
            let name = e.file_name().to_str().unwrap_or("");
            if name.starts_with('.') && name != ".wsb" {
                return false;
            }
            if e.file_type().is_dir() && e.path().parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".wsb") {
                return false;
            }
            !matches!(name, "target" | "node_modules" | "__pycache__")
        })
    {
        let entry = entry.with_context(|| format!("walking {}", root.display()))?;
        let in_wsb = entry.path().parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".wsb");
        if entry.file_type().is_file() && entry.file_name() == "state.json" && in_wsb {
            candidates.insert(entry.path().to_path_buf());
        }
    }
    // What the walk found is rewritten FIRST. The superproject's own file is
    // among it, and the tree below cannot be discovered while that file is in
    // the old shape: discovery reads the ignored submodules from it, and a
    // file that names its place is refused — by the very message that sends
    // somebody here.
    let mut rewritten = Vec::new();
    for path in &candidates {
        if relocate_one(path)? {
            rewritten.push(path.clone());
        }
    }

    // Then every repository of the tree, whatever it is called. The walk
    // skips hidden directories, and `.github` is a submodule with a project
    // of its own: its file was left in the old shape, and `wsb check` refused
    // it after the rewrite reported nothing left to do.
    let tree = crate::fleet::git::SubmoduleTree::discover_allow_empty(root)
        .with_context(|| format!("discovering the repositories under {}", root.display()))?;
    for node in tree.top_down() {
        let file = node.abs_path.join(".wsb").join("state.json");
        if file.is_file() && !candidates.contains(&file) && relocate_one(&file)? {
            rewritten.push(file);
        }
    }
    Ok(rewritten)
}

/// Rewrite one state file without the place it names; whether it named one.
fn relocate_one(path: &Path) -> Result<bool> {
    let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
    let mut value: serde_json::Value =
        serde_json::from_str(&text).with_context(|| format!("{} is not JSON", path.display()))?;
    let Some(stored_root) = value
        .as_object_mut()
        .and_then(|object| object.remove("project_root"))
    else {
        return Ok(false);
    };
    let stored_root = PathBuf::from(
        stored_root
            .as_str()
            .with_context(|| format!("{}: `project_root` is not a path", path.display()))?,
    );
    if let Some(entries) = value.get_mut("wstemplate_entries").and_then(|e| e.as_array_mut()) {
        for stored in entries {
            let Some(old) = stored.get("root").and_then(|r| r.as_str()).map(PathBuf::from) else {
                continue;
            };
            if old.is_absolute() {
                let relative = relative_to(&old, &stored_root)
                    .with_context(|| format!("{}: relocating its scan root", path.display()))?;
                stored["root"] = serde_json::Value::String(relative.display().to_string());
            }
        }
    }
    let content = serde_json::to_string_pretty(&value)?;
    fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
    Ok(true)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn test_workspace_state_initialize() {
        let temp_dir = TempDir::new().unwrap();
        let state = WorkspaceState::initialize(temp_dir.path(), None).unwrap();

        assert_eq!(state.version, 1);
        assert_eq!(state.project_root, temp_dir.path());
        assert!(temp_dir.path().join(".wsb").exists());
        assert!(temp_dir.path().join(".wsb").join("templates").exists());
        assert!(temp_dir.path().join(".wsb").join("state.json").exists());
        assert_eq!(state.version_suffix, None);
        assert!(state.wstemplate_entries.is_empty());
        assert!(!state.completion_hint_shown);
    }

    #[test]
    fn test_workspace_state_save_load() {
        let temp_dir = TempDir::new().unwrap();
        let mut state = WorkspaceState::initialize(temp_dir.path(), None).unwrap();

        // Set some tool config
        #[derive(Serialize, Deserialize, PartialEq, Debug)]
        struct TestConfig {
            enabled: bool,
            value: String,
        }

        let test_config = TestConfig {
            enabled: true,
            value: "test".to_string(),
        };

        state.set_tool_config("test_tool", &test_config).unwrap();
        state.save(temp_dir.path()).unwrap();

        // Load and verify
        let loaded_state = WorkspaceState::load(temp_dir.path()).unwrap();
        let loaded_config: TestConfig = loaded_state.get_tool_config("test_tool").unwrap();

        assert_eq!(loaded_config, test_config);
    }

    /// TEST12561: a project copied to another place scans from that place.
    ///
    /// A snapshot's checkouts carry each project's state as the workspace
    /// wrote it. The file once named the workspace's absolute paths, and a
    /// render in the snapshot found every project where it had been copied
    /// FROM — a ship stopped at its first regeneration, "the template is not
    /// under any project registered" in its snapshot. Nothing in the file
    /// names a place now: copied, a project is where its `.wsb` is, and its
    /// scan root is the same distance above it.
    #[test]
    fn test12561_a_copied_project_scans_from_where_it_is() {
        let temp_dir = TempDir::new().unwrap();
        let written = temp_dir.path().join("ws");
        let copied = temp_dir.path().join("snapshots").join("ship");
        let project = PathBuf::from("capdag").join("capdag-rs");
        let mut state = WorkspaceState::initialize(&written.join(&project), None).unwrap();
        state.set_wstemplate_entry(WstemplateEntry { alias: "capdag_rs".into(), root: written.clone() });
        state.save(&written.join(&project)).unwrap();

        let text = fs::read_to_string(written.join(&project).join(".wsb").join("state.json")).unwrap();
        assert!(!text.contains(&written.display().to_string()), "the file names where it was written: {text}");

        fs::create_dir_all(copied.join(&project).join(".wsb")).unwrap();
        fs::write(copied.join(&project).join(".wsb").join("state.json"), &text).unwrap();
        let loaded = WorkspaceState::load(&copied.join(&project)).unwrap();
        assert_eq!(loaded.project_root, copied.join(&project));
        assert_eq!(loaded.wstemplate_entries[0].root, copied);
        assert_eq!(WorkspaceState::load(&written.join(&project)).unwrap().wstemplate_entries[0].root, written);
    }

    /// An absolute place that is not this test's, spelled as this platform
    /// spells one. A placed state file names where it was written, and
    /// `/Users/elsewhere/ws` has no drive, so on Windows it is not absolute at
    /// all and was resolved under `C:` instead of being recognised as a place.
    fn elsewhere(rest: &[&str]) -> String {
        let base = if cfg!(windows) {
            std::path::PathBuf::from(r"C:\elsewhere\ws")
        } else {
            std::path::PathBuf::from("/Users/elsewhere/ws")
        };
        rest.iter().fold(base, |path, part| path.join(part)).display().to_string()
    }

    /// TEST12564: a state file that names its place is refused, and
    /// `relocate_state_files` rewrites it to mean the same from where it is.
    #[test]
    fn test12564_a_placed_state_file_is_refused_and_rewritten() {
        let temp_dir = TempDir::new().unwrap();
        let workspace = temp_dir.path().join("ws");
        let project = workspace.join("capdag").join("capdag-rs");
        fs::create_dir_all(project.join(".wsb")).unwrap();
        let old = serde_json::json!({
            "version": 1,
            "project_root": elsewhere(&["capdag", "capdag-rs"]),
            "project_name": "capdag-rs",
            "tools": {},
            "wstemplate_entries": [{"alias": "capdag_rs", "root": elsewhere(&[])}]
        });
        fs::write(project.join(".wsb").join("state.json"), old.to_string()).unwrap();

        let refused = format!("{:#}", WorkspaceState::load(&project).unwrap_err());
        assert!(refused.contains("wsb workspace relocate-state"), "{refused}");

        let rewritten = relocate_state_files(&workspace).unwrap();
        assert_eq!(rewritten, vec![project.join(".wsb").join("state.json")]);
        let loaded = WorkspaceState::load(&project).unwrap();
        assert_eq!(loaded.wstemplate_entries[0].root, workspace, "the scan root kept its distance above the project");
        assert_eq!(loaded.project_name.as_deref(), Some("capdag-rs"));
        assert!(relocate_state_files(&workspace).unwrap().is_empty(), "a rewritten file was rewritten again");

        let absolute = serde_json::json!({
            "version": 1, "project_name": "x", "tools": {},
            "wstemplate_entries": [{"alias": "x", "root": elsewhere(&["abs"])}]
        });
        fs::write(project.join(".wsb").join("state.json"), absolute.to_string()).unwrap();
        let refused = format!("{:#}", WorkspaceState::load(&project).unwrap_err());
        assert!(refused.contains("absolute path"), "{refused}");
    }

    /// TEST12597: a submodule whose name starts with a dot has its state file
    /// rewritten too.
    ///
    /// The walk skips hidden directories, so `.github`'s file stayed in the old
    /// shape: the rewrite said every file already named no place, and `wsb
    /// check` refused that one in the same minute.
    #[test]
    fn test12597_a_hidden_submodules_state_is_rewritten() {
        let temp_dir = TempDir::new().unwrap();
        let workspace = temp_dir.path().join("ws");
        let hidden = workspace.join(".github");
        fs::create_dir_all(hidden.join(".wsb")).unwrap();
        let status = crate::git_backend::git_command()
            .arg("-C")
            .arg(&workspace)
            .args(["init", "--quiet"])
            .status()
            .unwrap();
        assert!(status.success());
        fs::write(
            workspace.join(".gitmodules"),
            "[submodule \".github\"]\n\tpath = .github\n\turl = ./.github\n",
        )
        .unwrap();
        let old = serde_json::json!({
            "version": 1,
            "project_root": elsewhere(&[".github"]),
            "project_name": ".github",
            "tools": {},
            "wstemplate_entries": [{"alias": "github", "root": elsewhere(&[])}]
        });
        fs::write(hidden.join(".wsb").join("state.json"), old.to_string()).unwrap();

        let rewritten = relocate_state_files(&workspace).unwrap();
        assert!(
            rewritten.iter().any(|path| path.ends_with(".github/.wsb/state.json")),
            "the hidden submodule's state was not rewritten: {rewritten:?}"
        );
        assert_eq!(WorkspaceState::load(&hidden).unwrap().wstemplate_entries[0].root, workspace);
    }

    /// TEST20011: the superproject's own state file in the old shape is
    /// rewritten, along with what is below it.
    ///
    /// Finding the repositories reads the superproject's file for the
    /// submodules it ignores, and a file in the old shape is refused. So the
    /// rewrite failed on exactly the workspace that needed it — refusing with
    /// the message that tells somebody to run it.
    #[test]
    fn test20011_the_superprojects_own_old_state_is_rewritten() {
        let temp_dir = TempDir::new().unwrap();
        let workspace = temp_dir.path().join("ws");
        fs::create_dir_all(workspace.join(".wsb")).unwrap();
        let status = crate::git_backend::git_command()
            .arg("-C")
            .arg(&workspace)
            .args(["init", "--quiet"])
            .status()
            .unwrap();
        assert!(status.success());
        let old = serde_json::json!({
            "version": 1,
            "project_root": elsewhere(&[]),
            "project_name": "ws",
            "tools": {},
            "wstemplate_entries": [{"alias": "ws", "root": elsewhere(&[])}]
        });
        fs::write(workspace.join(".wsb").join("state.json"), old.to_string()).unwrap();

        let rewritten = relocate_state_files(&workspace).expect("the rewrite reaches the superproject's file");
        assert_eq!(rewritten, vec![workspace.join(".wsb").join("state.json")]);
        assert_eq!(WorkspaceState::load(&workspace).unwrap().wstemplate_entries[0].root, workspace);
    }

    #[test]
    fn test_wstemplate_entry_roundtrips_through_state_json() {
        let temp_dir = TempDir::new().unwrap();
        let mut state = WorkspaceState::initialize(temp_dir.path(), None).unwrap();

        // An absolute root on every platform: `/some/scan/root` is not one on
        // Windows, and a state file stores a root relative to its own place.
        let entry = WstemplateEntry {
            alias: "my_project".to_string(),
            root: temp_dir.path().join("some").join("scan").join("root"),
        };
        state.set_wstemplate_entry(entry.clone());
        state.save(temp_dir.path()).unwrap();

        let loaded = WorkspaceState::load(temp_dir.path()).unwrap();
        assert_eq!(loaded.wstemplate_entries.len(), 1);
        assert_eq!(loaded.wstemplate_entries[0], entry);
    }

    #[test]
    fn test_state_without_wstemplate_entries_loads_with_empty_vec() {
        // State JSON written without the wstemplate_entries field must deserialize
        // to an empty Vec (via serde default), not fail.
        let temp_dir = TempDir::new().unwrap();
        let ws_dir = temp_dir.path().join(".wsb");
        std::fs::create_dir_all(&ws_dir).unwrap();
        let state_json = r#"{
            "version": 1,
            "project_name": "test",
            "tools": {}
        }"#;
        std::fs::write(ws_dir.join("state.json"), state_json).unwrap();

        let state = WorkspaceState::load(temp_dir.path()).unwrap();
        assert!(
            state.wstemplate_entries.is_empty(),
            "missing wstemplate_entries field must default to empty Vec"
        );
        assert!(
            !state.completion_hint_shown,
            "missing completion_hint_shown field must default to false"
        );
        assert_eq!(state.version_suffix, None);
    }

    #[test]
    fn test_wstemplate_entry_returns_configured_entry() {
        let temp_dir = TempDir::new().unwrap();
        let mut state = WorkspaceState::initialize(temp_dir.path(), None).unwrap();
        assert!(state.wstemplate_entry().is_none());

        state.set_wstemplate_entry(WstemplateEntry {
            alias: "my_alias".to_string(),
            root: PathBuf::from("/scan/root"),
        });
        assert_eq!(state.wstemplate_entry().unwrap().alias, "my_alias");
    }

    #[test]
    fn test_set_wstemplate_entry_replaces_not_appends() {
        let temp_dir = TempDir::new().unwrap();
        let mut state = WorkspaceState::initialize(temp_dir.path(), None).unwrap();

        state.set_wstemplate_entry(WstemplateEntry {
            alias: "first".to_string(),
            root: PathBuf::from("/scan/root"),
        });
        state.set_wstemplate_entry(WstemplateEntry {
            alias: "second".to_string(),
            root: PathBuf::from("/scan/root"),
        });

        assert_eq!(state.wstemplate_entries.len(), 1);
        assert_eq!(state.wstemplate_entry().unwrap().alias, "second");
    }

    #[test]
    fn test_clear_wstemplate_entry() {
        let temp_dir = TempDir::new().unwrap();
        let mut state = WorkspaceState::initialize(temp_dir.path(), None).unwrap();

        state.set_wstemplate_entry(WstemplateEntry {
            alias: "test".to_string(),
            root: PathBuf::from("/root"),
        });
        assert!(state.wstemplate_entry().is_some());

        state.clear_wstemplate_entry();
        assert!(state.wstemplate_entry().is_none());
        assert!(state.wstemplate_entries.is_empty());
    }

    #[test]
    fn test_effective_wstemplate_entry_defaults_to_project_root_and_alias() {
        let temp_dir = TempDir::new().unwrap();
        let state = WorkspaceState::initialize(temp_dir.path(), None).unwrap();

        let effective = state.effective_wstemplate_entry();
        assert_eq!(effective.root, temp_dir.path());
        assert_eq!(
            effective.alias,
            crate::state::wstemplate::WstemplateEngine::derive_alias(temp_dir.path())
        );
    }

    #[test]
    fn test_effective_wstemplate_entry_prefers_explicit_entry() {
        let temp_dir = TempDir::new().unwrap();
        let mut state = WorkspaceState::initialize(temp_dir.path(), None).unwrap();
        let explicit = WstemplateEntry {
            alias: "wsb".to_string(),
            root: temp_dir.path().parent().unwrap().to_path_buf(),
        };
        state.set_wstemplate_entry(explicit.clone());

        assert_eq!(state.effective_wstemplate_entry(), explicit);
    }

    #[test]
    fn test_load_fails_hard_on_multi_entry() {
        let temp_dir = TempDir::new().unwrap();
        let ws_dir = temp_dir.path().join(".wsb");
        fs::create_dir_all(&ws_dir).unwrap();

        let state_json = serde_json::json!({
            "version": 1,
            "project_name": "test",
            "tools": {},
            "wstemplate_entries": [
                {"alias": "self_alias", "root": "."},
                {"alias": "other_proj", "root": ".."},
                {"alias": "third_proj", "root": "../.."}
            ]
        });
        fs::write(
            ws_dir.join("state.json"),
            serde_json::to_string_pretty(&state_json).unwrap(),
        )
        .unwrap();

        let err = WorkspaceState::load(temp_dir.path()).unwrap_err();
        let msg = format!("{:#}", err);
        assert!(
            msg.contains("3 wstemplate entries"),
            "error must report the count, got: {}",
            msg
        );
        assert!(
            msg.contains("expected at most 1"),
            "error must explain the constraint, got: {}",
            msg
        );
    }

    #[test]
    fn test_completion_hint_shown_persists() {
        let temp_dir = TempDir::new().unwrap();
        let mut state = WorkspaceState::initialize(temp_dir.path(), None).unwrap();
        assert!(!state.completion_hint_shown);

        state.completion_hint_shown = true;
        state.save(temp_dir.path()).unwrap();

        let loaded = WorkspaceState::load(temp_dir.path()).unwrap();
        assert!(
            loaded.completion_hint_shown,
            "completion_hint_shown must persist through save/load"
        );
    }

    #[test]
    fn test_version_suffix_persists() {
        let temp_dir = TempDir::new().unwrap();
        let mut state = WorkspaceState::initialize(temp_dir.path(), None).unwrap();
        state.version_suffix = Some("release".to_string());
        state.save(temp_dir.path()).unwrap();

        let loaded = WorkspaceState::load(temp_dir.path()).unwrap();
        assert_eq!(loaded.version_suffix.as_deref(), Some("release"));
        assert_eq!(loaded.version_suffix(), Some("release"));
    }

    #[test]
    fn test_format_version_uses_state_suffix_or_cli_override() {
        let temp_dir = TempDir::new().unwrap();
        let mut state = WorkspaceState::initialize(temp_dir.path(), None).unwrap();
        state.version_suffix = Some("release".to_string());

        let version_info = crate::state::VersionInfo {
            major_version: "v1".to_string(),
            minor_version: 2,
            patch_version: 3,
            full_version: "1.2.3-anything".to_string(),
        };

        assert_eq!(state.format_version(&version_info, None), "1.2.3-release");
        assert_eq!(
            state.format_version(&version_info, Some("candidate")),
            "1.2.3-candidate"
        );
        assert_eq!(
            state.format_version(&version_info, Some("")),
            "1.2.3-release"
        );
    }

    #[test]
    fn test_initialize_uses_directory_name_by_default() {
        let temp_dir = TempDir::new().unwrap();
        let named_dir = temp_dir.path().join("capdag-js");
        fs::create_dir_all(&named_dir).unwrap();

        let state = WorkspaceState::initialize(&named_dir, None).unwrap();
        assert_eq!(state.project_name.as_deref(), Some("capdag-js"));
    }

    #[test]
    fn test_initialize_uses_explicit_name_over_directory() {
        let temp_dir = TempDir::new().unwrap();
        let named_dir = temp_dir.path().join("capdag-js");
        fs::create_dir_all(&named_dir).unwrap();

        let state = WorkspaceState::initialize(&named_dir, Some("my-project")).unwrap();
        assert_eq!(state.project_name.as_deref(), Some("my-project"));
    }

    #[test]
    fn test_initialize_ignores_package_file_names() {
        let temp_dir = TempDir::new().unwrap();
        let named_dir = temp_dir.path().join("capdag-js");
        fs::create_dir_all(&named_dir).unwrap();

        // Even with a package.json that has a different name, the directory name wins
        fs::write(
            named_dir.join("package.json"),
            r#"{ "name": "capdag", "version": "1.0.0" }"#,
        )
        .unwrap();

        let state = WorkspaceState::initialize(&named_dir, None).unwrap();
        assert_eq!(
            state.project_name.as_deref(),
            Some("capdag-js"),
            "project_name must come from the directory, not package.json"
        );
    }

    #[test]
    fn test_tool_directory_paths() {
        let temp_dir = TempDir::new().unwrap();
        let state = WorkspaceState::initialize(temp_dir.path(), None).unwrap();

        assert_eq!(state.workspace_dir(), temp_dir.path().join(".wsb"));
        assert_eq!(
            state.tool_dir("state"),
            temp_dir.path().join(".wsb").join("state")
        );
    }

    #[test]
    fn test_load_fails_on_missing_workspace() {
        let temp_dir = TempDir::new().unwrap();
        let err = WorkspaceState::load(temp_dir.path()).unwrap_err();
        let msg = format!("{:#}", err);
        assert!(
            msg.contains("Not in a wsb workspace"),
            "error must describe the problem, got: {}",
            msg
        );
        assert!(
            msg.contains("wsb init"),
            "error must suggest running wsb init, got: {}",
            msg
        );
    }
}