use crate::prelude::*;
use std::fs;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WstemplateEntry {
pub root: PathBuf,
pub alias: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WorkspaceState {
pub version: u32,
#[serde(default)]
pub version_suffix: Option<String>,
pub project_root: PathBuf,
pub project_name: Option<String>,
pub tools: HashMap<String, serde_json::Value>,
#[serde(default)]
pub wstemplate_entries: Vec<WstemplateEntry>,
#[serde(default)]
pub completion_hint_shown: bool,
}
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(),
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")?;
fs::create_dir_all(workspace_dir.join("logs"))
.context("Failed to create logs 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 mut state: Self =
serde_json::from_str(&content).context("Failed to parse workspace state file")?;
state.project_root = project_root.to_path_buf();
if state.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(),
state.wstemplate_entries.len(),
state_file.display()
);
}
Ok(state)
}
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 state_file = workspace_dir.join("state.json");
let content =
serde_json::to_string_pretty(self).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();
}
}
#[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 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: PathBuf::from("/some/scan/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_root": "/some/path",
"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_root": temp_dir.path().to_str().unwrap(),
"project_name": "test",
"tools": {},
"wstemplate_entries": [
{"alias": "self_alias", "root": "/scan/root"},
{"alias": "other_proj", "root": "/some/other"},
{"alias": "third_proj", "root": "/yet/another"}
]
});
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
);
}
}