use std::path::{Path, PathBuf};
use std::sync::Arc;
pub(crate) struct SessionWorkspace {
pub(crate) root: PathBuf,
pub(crate) workspace: Arc<saya_harness::workspace::Workspace>,
}
pub(crate) fn resolve_root(explicit: Option<&Path>, cwd: &Path) -> Result<Option<PathBuf>, String> {
if let Some(dir) = explicit {
let canonical = std::fs::canonicalize(dir).map_err(|error| {
format!(
"--workspace {} could not be resolved: {error}; name an existing directory",
dir.display()
)
})?;
if !canonical.is_dir() {
return Err(format!(
"--workspace {} is not a directory; name an existing directory",
dir.display()
));
}
return Ok(Some(canonical));
}
let Some(root) = worktree_root(cwd) else {
return Ok(None);
};
let canonical = std::fs::canonicalize(root)
.map_err(|error| format!("the workspace root could not be resolved: {error}"))?;
Ok(Some(canonical))
}
pub(crate) fn bind(
explicit: Option<&Path>,
cwd: &Path,
) -> Result<Option<SessionWorkspace>, String> {
let Some(root) = resolve_root(explicit, cwd)? else {
return Ok(None);
};
refuse_state_overlap(&root)?;
let workspace = saya_harness::workspace::Workspace::open(&root).map_err(|error| {
format!(
"the workspace root {} could not be opened: {error}",
root.display()
)
})?;
Ok(Some(SessionWorkspace {
root,
workspace: Arc::new(workspace),
}))
}
pub(crate) const NO_WORKSPACE_NOTICE: &str = "No workspace is bound (outside any worktree, no `--workspace`): \
file tools are unavailable; launch inside a git worktree or pass `--workspace <dir>`.";
pub(crate) fn bind_from_pins(
explicit: Option<&Path>,
pinned_root: Option<&str>,
walk_when_unpinned: bool,
cwd: &Path,
) -> Result<(Option<SessionWorkspace>, Option<String>), String> {
if let Some(dir) = explicit {
let bound = bind(Some(dir), cwd)?.expect("an explicit bind returns the root");
return Ok((Some(bound), None));
}
let Some(pin) = pinned_root else {
let bound = if walk_when_unpinned {
bind(None, cwd)?
} else {
None
};
let notice = match (&bound, walk_when_unpinned) {
(None, true) => Some(NO_WORKSPACE_NOTICE.to_owned()),
_ => None,
};
return Ok((bound, notice));
};
let recorded = PathBuf::from(pin);
if !recorded.exists() {
return Ok((
None,
Some(format!(
"the recorded workspace root {pin} no longer exists: no workspace is bound, \
so file reads and writes are unavailable this session"
)),
));
}
Ok((bind(Some(&recorded), cwd)?, None))
}
fn worktree_root(start: &Path) -> Option<PathBuf> {
let mut current = Some(start.to_path_buf());
while let Some(dir) = current {
if dir.join(".git").exists() {
return Some(dir);
}
current = dir.parent().map(Path::to_path_buf);
}
None
}
fn refuse_state_overlap(root: &Path) -> Result<(), String> {
let stated: [Option<PathBuf>; 2] = [
state_root_for_check("SAYA_RUNS_DIR", "runs"),
state_root_for_check("SAYA_SESSION_DIR", "sessions"),
];
for state_root in stated.iter().flatten() {
check_state_overlap(root, state_root)?;
}
Ok(())
}
fn state_root_for_check(override_env: &str, subdir: &str) -> Option<PathBuf> {
if let Some(value) = std::env::var_os(override_env) {
return Some(canonical_or_absolute(Path::new(&value)));
}
for (env, leaf) in [
("XDG_DATA_HOME", subdir),
("APPDATA", subdir),
("HOME", subdir),
] {
if let Some(value) = std::env::var_os(env) {
return Some(canonical_or_absolute(
&Path::new(&value).join("saya").join(leaf),
));
}
}
None
}
fn check_state_overlap(root: &Path, state_root: &Path) -> Result<(), String> {
{
if root.starts_with(state_root) || state_root.starts_with(root) {
return Err(format!(
"the workspace root {} cannot be bound: it overlaps the saya state root {} — \
a session's children are bounded to the workspace root, so a state root \
inside it would put the runs' journals or this session's scratch, lock, and \
transcript within their reach, and a workspace inside a state root would \
put the workspace under the file tools' reach; set SAYA_RUNS_DIR / \
SAYA_SESSION_DIR outside the workspace tree",
root.display(),
state_root.display()
));
}
}
Ok(())
}
fn canonical_or_absolute(path: &Path) -> PathBuf {
if let Ok(canonical) = std::fs::canonicalize(path) {
return canonical;
}
if path.is_absolute() {
return path.to_path_buf();
}
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
}
#[cfg(test)]
#[path = "session_workspace_tests.rs"]
mod tests;