use crate::prelude::*;
use std::fs;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WstemplateEntry {
pub root: PathBuf,
pub alias: String,
}
#[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>,
pub ignored_submodules: Vec<String>,
pub completion_hint_shown: bool,
}
#[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,
}
#[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 {
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")?;
fs::create_dir_all(workspace_dir.join("templates"))
.context("Failed to create templates directory")?;
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)
}
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()))?;
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,
})
}
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")?;
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,
}
}
pub fn workspace_dir(&self) -> PathBuf {
self.project_root.join(".wsb")
}
pub fn tool_dir(&self, tool_name: &str) -> PathBuf {
self.workspace_dir().join(tool_name)
}
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())
}
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(())
}
pub fn wstemplate_entry(&self) -> Option<&WstemplateEntry> {
self.wstemplate_entries.first()
}
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(),
})
}
pub fn set_wstemplate_entry(&mut self, entry: WstemplateEntry) {
self.wstemplate_entries = vec![entry];
}
pub fn clear_wstemplate_entry(&mut self) {
self.wstemplate_entries.clear();
}
}
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
}
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)
}
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());
}
}
let mut rewritten = Vec::new();
for path in &candidates {
if relocate_one(path)? {
rewritten.push(path.clone());
}
}
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)
}
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();
#[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();
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);
}
#[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);
}
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()
}
#[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}");
}
#[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);
}
#[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();
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() {
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();
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
);
}
}