use ostraka_adapter::Profile;
use ostraka_core::config::Config;
use std::path::{Path, PathBuf};
type Loaded<T> = Result<T, Box<dyn std::error::Error>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repository {
pub name: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepositoriesFrom {
Default,
Config,
Flag,
}
impl RepositoriesFrom {
pub fn describe(self) -> &'static str {
match self {
RepositoriesFrom::Default => "the default",
RepositoriesFrom::Config => "[workspace] repositories in .ostraka/ostraka.toml",
RepositoriesFrom::Flag => "--repositories",
}
}
}
#[derive(Debug, Default, serde::Deserialize)]
struct Layout {
#[serde(default)]
workspace: LayoutTable,
}
#[derive(Debug, Default, serde::Deserialize)]
struct LayoutTable {
#[serde(default)]
repositories: Option<String>,
}
fn resolve(given: &str, base: &Path, home: Option<&Path>) -> PathBuf {
let path = match (given.strip_prefix('~'), home) {
(Some(rest), Some(home)) if rest.is_empty() || rest.starts_with('/') => {
home.join(rest.trim_start_matches('/'))
}
_ => PathBuf::from(given),
};
let path = if path.is_absolute() {
path
} else {
base.join(path)
};
path.canonicalize().unwrap_or_else(|_| clean(&path))
}
fn clean(path: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for part in path.components() {
match part {
Component::CurDir => {}
Component::ParentDir => match out.components().next_back() {
Some(Component::Normal(_)) => {
out.pop();
}
Some(Component::RootDir | Component::Prefix(_)) => {}
_ => out.push(".."),
},
other => out.push(other),
}
}
out
}
pub(crate) fn resolve_flag(given: &Path) -> std::io::Result<PathBuf> {
let here = std::env::current_dir()?;
Ok(resolve(&given.to_string_lossy(), &here, home().as_deref()))
}
fn home() -> Option<PathBuf> {
std::env::var_os("HOME")
.filter(|h| !h.is_empty())
.map(PathBuf::from)
}
#[derive(Debug, Clone)]
pub struct Workspace {
pub root: PathBuf,
pub repositories: PathBuf,
pub repositories_from: RepositoriesFrom,
}
impl Workspace {
pub fn at(root: &Path) -> Self {
let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let declared = std::fs::read_to_string(root.join(".ostraka").join("ostraka.toml"))
.ok()
.and_then(|text| toml::from_str::<Layout>(&text).ok())
.and_then(|layout| layout.workspace.repositories)
.filter(|given| !given.trim().is_empty());
let (repositories, repositories_from) = match declared {
Some(given) => (
resolve(&given, &root, home().as_deref()),
RepositoriesFrom::Config,
),
None => (root.join("repositories"), RepositoriesFrom::Default),
};
Self {
root,
repositories,
repositories_from,
}
}
pub fn open(root: &Path, flag: Option<&Path>) -> Loaded<Self> {
let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let file = root.join(".ostraka").join("ostraka.toml");
let declared = match std::fs::read_to_string(&file) {
Ok(text) => {
toml::from_str::<Layout>(&text)
.map_err(|e| format!("{}: {e}", file.display()))?
.workspace
.repositories
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(format!("{}: {e}", file.display()).into()),
};
let (repositories, repositories_from) = if let Some(flag) = flag {
(resolve_flag(flag)?, RepositoriesFrom::Flag)
} else if let Some(given) = declared {
if given.trim().is_empty() {
return Err(format!(
"{}: [workspace] repositories is empty — remove it for the default, \
or name a directory",
file.display()
)
.into());
}
(
resolve(&given, &root, home().as_deref()),
RepositoriesFrom::Config,
)
} else {
return Ok(Self {
repositories: root.join("repositories"),
root,
repositories_from: RepositoriesFrom::Default,
});
};
if repositories == root {
return Err(format!(
"the repositories directory cannot be the workspace itself ({}) — set by {}",
root.display(),
repositories_from.describe()
)
.into());
}
if repositories.exists() && !repositories.is_dir() {
return Err(format!(
"the repositories directory {} is a file, not a directory — set by {}",
repositories.display(),
repositories_from.describe()
)
.into());
}
if !repositories.is_dir() {
return Err(format!(
"the repositories directory {} does not exist — set by {}; create it, or \
point it at one that does",
repositories.display(),
repositories_from.describe()
)
.into());
}
Ok(Self {
root,
repositories,
repositories_from,
})
}
pub fn ostraka(&self) -> PathBuf {
self.root.join(".ostraka")
}
pub fn config_path(&self) -> PathBuf {
self.ostraka().join("ostraka.toml")
}
pub fn adapters(&self) -> PathBuf {
self.ostraka().join("adapters")
}
pub fn records(&self) -> PathBuf {
self.ostraka()
}
pub fn repositories_dir(&self) -> PathBuf {
self.repositories.clone()
}
pub fn notes(&self) -> PathBuf {
self.root.join("notes")
}
pub fn skills(&self) -> PathBuf {
self.root.join("skills")
}
pub fn skills_if_present(&self) -> Option<PathBuf> {
let skills = self.skills();
skills.is_dir().then_some(skills)
}
pub fn notes_if_present(&self) -> Option<PathBuf> {
let notes = self.notes();
notes.is_dir().then_some(notes)
}
pub fn declared(&self) -> bool {
self.config_path().is_file()
}
pub fn repositories(&self) -> Vec<Repository> {
let Ok(entries) = std::fs::read_dir(self.repositories_dir()) else {
return Vec::new();
};
let mut found: Vec<Repository> = entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.filter_map(|path| {
let name = path.file_name()?.to_string_lossy().into_owned();
Some(Repository { name, path })
})
.collect();
found.sort_by(|a, b| a.name.cmp(&b.name));
found
}
pub fn repository(&self, named: Option<&str>) -> Loaded<Repository> {
let found = self.repositories();
if let Some(name) = named {
return found.into_iter().find(|r| r.name == name).ok_or_else(|| {
format!(
"no repository {name:?} in {}",
self.repositories_dir().display()
)
.into()
});
}
match found.len() {
0 => Err(format!(
"no repositories in {} — clone what you want worked on into it, \
or `ostraka tui` starts one",
self.repositories_dir().display()
)
.into()),
1 => Ok(found.into_iter().next().expect("one")),
_ => Err(format!(
"this workspace has {} repositories; name one with --repository: {}",
found.len(),
found
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
.into()),
}
}
pub fn start_repository(&self, name: &str) -> Loaded<Repository> {
let name = name.trim();
if name.is_empty() {
return Err("a repository needs a name".into());
}
if name.starts_with('.') || name.contains(std::path::is_separator) || name.contains("..") {
return Err(format!("{name:?} is not a name a directory can have").into());
}
let path = self.repositories_dir().join(name);
if path.exists() {
return Err(format!("{name} is already here").into());
}
std::fs::create_dir_all(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let out = std::process::Command::new("git")
.args(["init", "-q", "-b", "main"])
.current_dir(&path)
.output()
.map_err(|e| format!("git init: {e}"))?;
if !out.status.success() {
let _ = std::fs::remove_dir(&path);
return Err(format!(
"git init failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)
.into());
}
Ok(Repository {
name: name.to_string(),
path,
})
}
pub fn config_for(&self, repo: &Repository) -> Loaded<Config> {
let theirs = repo.path.join("ostraka.toml");
let path = if theirs.is_file() {
theirs
} else {
self.config_path()
};
let text =
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(Config::parse(&text)?)
}
pub fn config_source(&self, repo: &Repository) -> PathBuf {
let theirs = repo.path.join("ostraka.toml");
if theirs.is_file() {
theirs
} else {
self.config_path()
}
}
pub fn config(&self) -> Loaded<Config> {
let path = self.config_path();
let text =
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(Config::parse(&text)?)
}
pub fn worktrees(&self, config: &Config) -> PathBuf {
self.root.join(&config.worktree.base)
}
pub fn profiles(&self) -> Loaded<Vec<Profile>> {
let dir = self.adapters();
if !dir.is_dir() {
return Err(format!("no adapters directory at {}", dir.display()).into());
}
let mut paths: Vec<_> = std::fs::read_dir(&dir)?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
.collect();
paths.sort();
let mut profiles = Vec::with_capacity(paths.len());
for path in paths {
let text = std::fs::read_to_string(&path)?;
profiles.push(Profile::parse(&text).map_err(|e| format!("{}: {e}", path.display()))?);
}
Ok(profiles)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn scratch(name: &str) -> PathBuf {
static NEXT: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"ostraka-ws-{}-{name}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("scratch");
dir
}
fn workspace(name: &str, repos: &[&str]) -> (PathBuf, Workspace) {
let dir = scratch(name);
std::fs::create_dir_all(dir.join(".ostraka/adapters")).expect("ostraka");
std::fs::write(
dir.join(".ostraka/ostraka.toml"),
"[gate]\nchecks = [{ name = \"t\", cmd = \"true\", required = true }]\n",
)
.expect("config");
for repo in repos {
std::fs::create_dir_all(dir.join("repositories").join(repo)).expect("repo");
}
let ws = Workspace::at(&dir);
(dir, ws)
}
fn declare(dir: &Path, table: &str) {
std::fs::write(
dir.join(".ostraka/ostraka.toml"),
format!(
"[gate]\nchecks = [{{ name = \"t\", cmd = \"true\", required = true }}]\n\n{table}"
),
)
.expect("config");
}
#[test]
fn the_repositories_directory_is_repositories_unless_somebody_says_otherwise() {
let (dir, ws) = workspace("default-layout", &["only"]);
assert_eq!(ws.repositories_dir(), ws.root.join("repositories"));
assert_eq!(ws.repositories_from, RepositoriesFrom::Default);
let opened = Workspace::open(&dir, None).expect("opens");
assert_eq!(opened.repositories_dir(), ws.root.join("repositories"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_workspace_can_keep_its_repositories_somewhere_else() {
let (dir, _) = workspace("custom-layout", &[]);
std::fs::create_dir_all(dir.join("code/alpha")).expect("repo");
declare(&dir, "[workspace]\nrepositories = \"code\"\n");
let ws = Workspace::open(&dir, None).expect("opens");
assert_eq!(ws.repositories_from, RepositoriesFrom::Config);
assert_eq!(ws.repositories_dir(), ws.root.join("code"));
assert_eq!(ws.repository(None).expect("found").name, "alpha");
let made = ws.start_repository("fresh").expect("starts");
assert!(
made.path.starts_with(ws.root.join("code")),
"{:?}",
made.path
);
assert!(!dir.join("repositories/fresh").exists());
assert_eq!(Workspace::at(&dir).repositories_dir(), ws.root.join("code"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_absolute_directory_outside_the_workspace_is_allowed() {
let (dir, _) = workspace("absolute-layout", &[]);
let elsewhere = scratch("elsewhere");
std::fs::create_dir_all(elsewhere.join("theirs")).expect("repo");
declare(
&dir,
&format!(
"[workspace]\nrepositories = \"{}\"\n",
elsewhere.display().to_string().replace('\\', "\\\\")
),
);
let ws = Workspace::open(&dir, None).expect("opens");
assert_eq!(ws.repository(None).expect("found").name, "theirs");
std::fs::remove_dir_all(&dir).ok();
std::fs::remove_dir_all(&elsewhere).ok();
}
#[test]
fn a_tilde_means_the_home_directory_and_nothing_else() {
let base = Path::new("/workspace");
let home = Path::new("/home/someone");
assert_eq!(
resolve("~/code", base, Some(home)),
PathBuf::from("/home/someone/code")
);
assert_eq!(
resolve("~", base, Some(home)),
PathBuf::from("/home/someone")
);
assert_eq!(
resolve("code", base, Some(home)),
PathBuf::from("/workspace/code")
);
assert_eq!(
resolve("~other/x", base, Some(home)),
PathBuf::from("/workspace/~other/x")
);
}
#[test]
fn a_path_that_does_not_exist_is_named_without_its_detours() {
let base = Path::new("/nonexistent-ostraka/ws");
assert_eq!(
resolve("../nowhere", base, None),
PathBuf::from("/nonexistent-ostraka/nowhere")
);
assert_eq!(
resolve("a/./b/../c", base, None),
PathBuf::from("/nonexistent-ostraka/ws/a/c")
);
assert_eq!(resolve("/../../x", base, None), PathBuf::from("/x"));
}
#[test]
fn a_config_that_is_there_but_cannot_be_read_is_an_error_not_a_default() {
let (dir, _) = workspace("unreadable-config", &[]);
let file = dir.join(".ostraka/ostraka.toml");
std::fs::remove_file(&file).expect("remove");
std::fs::create_dir_all(&file).expect("a directory where the file goes");
let err = Workspace::open(&dir, None)
.expect_err("must refuse")
.to_string();
assert!(err.contains("ostraka.toml"), "{err}");
std::fs::remove_dir_all(&file).expect("remove");
assert_eq!(
Workspace::open(&dir, None)
.expect("opens")
.repositories_from,
RepositoriesFrom::Default
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_flag_wins_over_the_file() {
let (dir, _) = workspace("flag-layout", &[]);
std::fs::create_dir_all(dir.join("code/a")).expect("repo");
std::fs::create_dir_all(dir.join("other/b")).expect("repo");
declare(&dir, "[workspace]\nrepositories = \"code\"\n");
let ws = Workspace::open(&dir, Some(&dir.join("other"))).expect("opens");
assert_eq!(ws.repositories_from, RepositoriesFrom::Flag);
assert_eq!(ws.repository(None).expect("found").name, "b");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_chosen_directory_that_is_not_there_is_named_rather_than_found_empty() {
let (dir, _) = workspace("missing-layout", &[]);
declare(&dir, "[workspace]\nrepositories = \"nowhere\"\n");
let err = Workspace::open(&dir, None)
.expect_err("must refuse")
.to_string();
assert!(err.contains("nowhere"), "{err}");
assert!(err.contains("[workspace] repositories"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_directory_that_would_list_the_workspace_itself_is_refused() {
let (dir, _) = workspace("self-layout", &[]);
for given in ["", "."] {
declare(&dir, &format!("[workspace]\nrepositories = \"{given}\"\n"));
assert!(
Workspace::open(&dir, None).is_err(),
"{given:?} was accepted"
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_unreadable_file_is_an_error_to_run_in_and_the_default_to_plan_with() {
let (dir, _) = workspace("broken-layout", &[]);
std::fs::write(
dir.join(".ostraka/ostraka.toml"),
"[workspace\nrepositories = ",
)
.expect("write");
let err = Workspace::open(&dir, None)
.expect_err("must refuse")
.to_string();
assert!(err.contains("ostraka.toml"), "{err}");
assert_eq!(
Workspace::at(&dir).repositories_from,
RepositoriesFrom::Default
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_workspace_resolves_where_it_is() {
let (dir, _) = workspace("relative", &["only"]);
let there = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&dir).expect("chdir");
let here = Workspace::at(Path::new("."));
std::env::set_current_dir(there).expect("chdir back");
assert!(here.root.is_absolute(), "{:?}", here.root);
let config = here.config().expect("parses");
assert!(here.worktrees(&config).is_absolute());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn one_repository_does_not_have_to_be_named() {
let (dir, ws) = workspace("one", &["only"]);
assert_eq!(ws.repository(None).expect("found").name, "only");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn several_repositories_have_to_be_named_and_the_error_lists_them() {
let (dir, ws) = workspace("several", &["alpha", "beta"]);
let complaint = ws.repository(None).expect_err("ambiguous").to_string();
assert!(complaint.contains("--repository"), "{complaint}");
assert!(complaint.contains("alpha, beta"), "{complaint}");
assert_eq!(ws.repository(Some("beta")).expect("named").name, "beta");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_workspace_with_nothing_cloned_into_it_says_what_to_do() {
let (dir, ws) = workspace("empty", &[]);
let complaint = ws.repository(None).expect_err("none").to_string();
assert!(
complaint.contains("clone what you want worked on"),
"{complaint}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_repository_can_be_started_here_rather_than_cloned() {
let (dir, ws) = workspace("start", &[]);
let made = ws.start_repository("fresh").expect("starts one");
assert_eq!(made.name, "fresh");
assert!(made.path.join(".git").is_dir(), "git never heard of it");
assert_eq!(ws.repositories(), vec![made]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_new_repository_is_left_needing_the_commit_a_worktree_branches_from() {
let (dir, ws) = workspace("half", &[]);
let made = ws.start_repository("fresh").expect("starts one");
assert!(!ostraka_runtime::worktree::has_a_commit(&made.path));
assert!(ostraka_runtime::worktree::is_repository(&made.path));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_name_that_is_not_a_name_is_refused_rather_than_obeyed() {
let (dir, ws) = workspace("names", &[]);
for bad in ["", " ", "../escape", "a/b", ".hidden"] {
assert!(ws.start_repository(bad).is_err(), "{bad:?} was accepted");
}
assert!(!dir.parent().expect("a parent").join("escape").exists());
ws.start_repository("taken").expect("starts one");
assert!(ws.start_repository("taken").is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_repository_that_brought_its_own_config_is_run_by_it() {
let (dir, ws) = workspace("own", &["theirs", "ours"]);
std::fs::write(
dir.join("repositories/theirs/ostraka.toml"),
"[gate]\nchecks = [{ name = \"theirs\", cmd = \"true\", required = true }]\n",
)
.expect("their config");
let theirs = ws.repository(Some("theirs")).expect("found");
let ours = ws.repository(Some("ours")).expect("found");
assert_eq!(
ws.config_for(&theirs).expect("parses").gate.checks[0].name,
"theirs"
);
assert_eq!(
ws.config_for(&ours).expect("parses").gate.checks[0].name,
"t"
);
assert!(
ws.config_source(&theirs)
.starts_with(dir.join("repositories"))
);
assert_eq!(ws.config_source(&ours), ws.config_path());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn worktrees_are_the_workspaces_rather_than_the_repositorys() {
let (dir, ws) = workspace("worktrees", &["only"]);
let config = ws.config().expect("parses");
assert_eq!(ws.worktrees(&config), dir.join(".ostraka/worktrees"));
std::fs::remove_dir_all(&dir).ok();
}
}