use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use thiserror::Error;
const ISSUES_DIR_NAME: &str = "issues";
const LOGS_DIR_NAME: &str = "logs";
const SESSIONS_DIR_NAME: &str = "sessions";
const SERVICE_DIR_NAME: &str = "service";
const SERVICE_STATE_FILE_NAME: &str = "state.json";
#[derive(Debug, Default)]
pub struct Workspace {
root: PathBuf,
issues_dir: OnceLock<PathBuf>,
logs_dir: OnceLock<PathBuf>,
sessions_dir: OnceLock<PathBuf>,
service_dir: OnceLock<PathBuf>,
service_state_file: OnceLock<PathBuf>,
}
#[derive(Debug, Error)]
pub enum WorkspaceRootError {
#[error("workspace.root {path} exists but is not a directory")]
NotADirectory { path: PathBuf },
#[error("workspace.root has no parent directory: {path}")]
NoParent { path: PathBuf },
#[error("failed to create workspace.root {path}: {source}")]
Create {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to stat workspace.root {path}: {source}")]
Stat {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
impl Workspace {
pub fn new(root: PathBuf) -> Self {
Self {
root,
..Default::default()
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn issues_dir(&self) -> &Path {
self.issues_dir.get_or_init(|| self.root.join(ISSUES_DIR_NAME))
}
pub fn logs_dir(&self) -> &Path {
self.logs_dir.get_or_init(|| self.root.join(LOGS_DIR_NAME))
}
pub fn sessions_dir(&self) -> &Path {
self.sessions_dir.get_or_init(|| self.root.join(SESSIONS_DIR_NAME))
}
pub fn service_dir(&self) -> &Path {
self.service_dir.get_or_init(|| self.root.join(SERVICE_DIR_NAME))
}
pub fn service_state_file(&self) -> &Path {
self
.service_state_file
.get_or_init(|| self.service_dir().join(SERVICE_STATE_FILE_NAME))
}
pub fn issue_workdir(&self, issue_id: &str) -> PathBuf {
self.issues_dir().join(issue_id)
}
pub fn issue_sessions_dir(&self, issue_id: &str) -> PathBuf {
self.sessions_dir().join(issue_id)
}
pub fn ensure_root(&self) -> Result<(), WorkspaceRootError> {
let workspace_root = self.root();
match std::fs::metadata(workspace_root) {
Ok(meta) if meta.is_dir() => Ok(()),
Ok(_) => Err(WorkspaceRootError::NotADirectory {
path: workspace_root.to_path_buf(),
}),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
let _parent = workspace_root.parent().ok_or_else(|| WorkspaceRootError::NoParent {
path: workspace_root.to_path_buf(),
})?;
std::fs::create_dir_all(workspace_root).map_err(|source| WorkspaceRootError::Create {
path: workspace_root.to_path_buf(),
source,
})
},
Err(err) => Err(WorkspaceRootError::Stat {
path: workspace_root.to_path_buf(),
source: err,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
fn ws() -> Workspace {
Workspace::new(PathBuf::from("/tmp/ws"))
}
#[test]
fn root_accessor_returns_input() {
let p = ws();
assert_eq!(p.root(), Path::new("/tmp/ws"));
}
#[test]
fn logs_and_sessions_dirs_live_under_root() {
let p = ws();
assert_eq!(p.logs_dir(), Path::new("/tmp/ws/logs"));
assert_eq!(p.sessions_dir(), Path::new("/tmp/ws/sessions"));
}
#[test]
fn issues_dir_lives_under_root_without_workflow_namespace_by_default() {
let p = ws();
assert_eq!(p.issues_dir(), Path::new("/tmp/ws/issues"));
}
#[test]
fn service_dir_and_state_file_live_under_root() {
let p = ws();
assert_eq!(p.service_dir(), Path::new("/tmp/ws/service"));
assert_eq!(p.service_state_file(), Path::new("/tmp/ws/service/state.json"));
}
#[test]
fn issue_workdir_nests_under_issues_dir() {
let p = ws();
assert_eq!(p.issue_workdir("VIK-1"), PathBuf::from("/tmp/ws/issues/VIK-1"));
}
#[test]
fn issue_sessions_dir_nests_issue_id_under_sessions() {
let p = ws();
assert_eq!(p.issue_sessions_dir("VIK-1"), PathBuf::from("/tmp/ws/sessions/VIK-1"));
}
#[test]
fn memoized_accessors_return_stable_address_across_calls() {
let p = ws();
let a: *const Path = p.logs_dir();
let b: *const Path = p.logs_dir();
assert_eq!(a, b, "logs_dir() must hand back the cached PathBuf");
let a: *const Path = p.sessions_dir();
let b: *const Path = p.sessions_dir();
assert_eq!(a, b, "sessions_dir() must hand back the cached PathBuf");
let a: *const Path = p.service_dir();
let b: *const Path = p.service_dir();
assert_eq!(a, b, "service_dir() must hand back the cached PathBuf");
let a: *const Path = p.service_state_file();
let b: *const Path = p.service_state_file();
assert_eq!(a, b, "service_state_file() must hand back the cached PathBuf");
}
#[test]
fn creates_when_parent_exists() {
let tempdir = tempfile::tempdir().unwrap();
let target = tempdir.path().join("root");
assert!(!target.exists());
Workspace::new(target.clone()).ensure_root().expect("create ok");
assert!(target.is_dir());
}
#[test]
fn ensure_root_creates_only_workspace_root() {
let tempdir = tempfile::tempdir().unwrap();
let target = tempdir.path().join("root");
Workspace::new(target.clone()).ensure_root().expect("create ok");
assert!(target.is_dir());
assert!(
!target.join(".vik").exists(),
"ensure_root must not create internal dirs"
);
}
#[test]
fn noop_when_already_a_directory() {
let tempdir = tempfile::TempDir::new().unwrap();
let path = tempdir.path();
Workspace::new(path.to_path_buf()).ensure_root().expect("noop ok");
assert!(path.is_dir());
}
#[test]
fn fails_when_target_is_a_file() {
let tempdir = tempfile::TempDir::new().unwrap();
let as_file = tempdir.path().join("not-a-dir");
File::create(&as_file).expect("touch");
let err = Workspace::new(as_file).ensure_root().expect_err("file must fail");
assert!(
matches!(err, WorkspaceRootError::NotADirectory { .. }),
"expected NotADirectory, got {err:?}"
);
}
}