use std::{
collections::BTreeSet,
env,
ffi::{OsStr, OsString},
fs,
io::{self, Write},
path::{Path, PathBuf},
str::FromStr,
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
#[cfg(unix)]
use std::io::Read;
use shepherd::{
Harness, ShepherdConfig,
loader::{self, ConfigContext, ConfigSource},
};
use crate::dispatch_service::trusted_git_executable;
#[derive(
Clone,
Copy,
Debug,
Default,
Eq,
Hash,
Ord,
PartialEq,
PartialOrd,
strum::AsRefStr,
strum::Display,
strum::EnumCount,
strum::EnumIs,
strum::EnumString,
strum::IntoStaticStr,
strum::VariantNames,
)]
#[strum(ascii_case_insensitive, serialize_all = "snake_case")]
pub enum OutputFormat {
#[default]
Text,
Json,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ContextInputs {
pub start_dir: PathBuf,
pub primary_fallback: Option<PathBuf>,
pub shepherd_home: Option<PathBuf>,
pub home_dir: Option<PathBuf>,
pub active_harness: Option<Harness>,
pub explicit_config: Option<PathBuf>,
pub output_format: OutputFormat,
pub verbosity: u8,
}
impl Default for ContextInputs {
fn default() -> Self {
Self {
start_dir: PathBuf::from("."),
primary_fallback: None,
shepherd_home: None,
home_dir: None,
active_harness: None,
explicit_config: None,
output_format: OutputFormat::Text,
verbosity: 0,
}
}
}
impl ContextInputs {
pub fn from_environment(start_dir: impl Into<PathBuf>) -> Result<Self, ContextError> {
Self::from_environment_with(start_dir, &SystemEnvironment)
}
pub fn from_environment_with(
start_dir: impl Into<PathBuf>,
environment: &dyn ContextEnvironment,
) -> Result<Self, ContextError> {
let shepherd_home = environment_path(environment, "SHEPHERD_HOME");
let home_dir = environment_path(environment, "HOME");
let active_harness = resolve_environment_harness(environment)?;
Ok(Self {
start_dir: start_dir.into(),
shepherd_home,
home_dir,
active_harness,
..Self::default()
})
}
}
pub trait ContextEnvironment {
fn var_os(&self, key: &OsStr) -> Option<OsString>;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SystemEnvironment;
impl ContextEnvironment for SystemEnvironment {
fn var_os(&self, key: &OsStr) -> Option<OsString> {
env::var_os(key)
}
}
pub trait Clock: Send + Sync + core::fmt::Debug {
fn now_unix_millis(&self) -> i64;
}
pub trait IdentifierSource: Send + core::fmt::Debug {
fn next_id(&mut self) -> String;
}
pub trait IoBoundary: Send + core::fmt::Debug {
fn read_stdin(&mut self, buffer: &mut String) -> io::Result<usize>;
fn write_stdout(&mut self, bytes: &[u8]) -> io::Result<()>;
fn write_stderr(&mut self, bytes: &[u8]) -> io::Result<()>;
}
#[derive(Debug)]
pub struct RuntimeBindings {
clock: Box<dyn Clock>,
identifiers: Box<dyn IdentifierSource>,
io: Box<dyn IoBoundary>,
}
impl RuntimeBindings {
pub fn new(
clock: Box<dyn Clock>,
identifiers: Box<dyn IdentifierSource>,
io: Box<dyn IoBoundary>,
) -> Self {
Self {
clock,
identifiers,
io,
}
}
pub fn system() -> Self {
Self::new(
Box::new(SystemClock),
Box::new(SystemIdentifiers),
Box::new(SystemIo),
)
}
}
pub trait ContextHost {
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
fn git_rev_parse(&self, cwd: &Path, argument: &str) -> io::Result<PathBuf>;
fn read_optional(&self, path: &Path) -> io::Result<Option<String>>;
fn symlink_metadata(&self, path: &Path) -> io::Result<fs::Metadata> {
fs::symlink_metadata(path)
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SystemHost;
impl ContextHost for SystemHost {
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
fs::canonicalize(path)
}
fn git_rev_parse(&self, cwd: &Path, argument: &str) -> io::Result<PathBuf> {
let git = trusted_git_executable().map_err(io::Error::other)?;
parse_git_output(
std::process::Command::new(git)
.env_clear()
.current_dir(cwd)
.args(["rev-parse", "--path-format=absolute", argument])
.output()?,
)
}
fn read_optional(&self, path: &Path) -> io::Result<Option<String>> {
match fs::read_to_string(path) {
Ok(contents) => Ok(Some(contents)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
}
#[cfg(unix)]
#[derive(Debug)]
pub struct ProjectRootAnchor {
primary: DescriptorRoot,
git_dir: Option<DescriptorRoot>,
git_common_dir: Option<DescriptorRoot>,
}
#[cfg(unix)]
#[derive(Debug)]
struct DescriptorRoot {
path: PathBuf,
directory: std::os::fd::OwnedFd,
}
#[cfg(unix)]
impl DescriptorRoot {
fn open(path: &Path) -> io::Result<Self> {
use rustix::fs::{Mode, OFlags, open, openat};
validate_absolute_path(path)?;
let mut directory = open(
"/",
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(io::Error::from)?;
let mut traversed = PathBuf::from("/");
for component in path.components() {
let std::path::Component::Normal(name) = component else {
continue;
};
traversed.push(name);
directory = openat(
&directory,
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(|error| project_anchor_error(&traversed, error))?;
}
Ok(Self {
path: path.to_path_buf(),
directory,
})
}
fn from_directory(path: PathBuf, directory: std::os::fd::OwnedFd) -> Self {
Self { path, directory }
}
fn duplicate(&self) -> io::Result<Self> {
Ok(Self {
path: self.path.clone(),
directory: rustix::io::dup(&self.directory).map_err(io::Error::from)?,
})
}
fn relative(&self, path: &Path) -> io::Result<PathBuf> {
let relative = path.strip_prefix(&self.path).map_err(|_| {
io::Error::new(
io::ErrorKind::PermissionDenied,
"path is outside the descriptor root",
)
})?;
validate_relative_path(relative)?;
Ok(relative.to_path_buf())
}
fn open_any(&self, path: &Path) -> io::Result<std::os::fd::OwnedFd> {
self.open_relative(&self.relative(path)?, false)
}
fn open_directory(&self, path: &Path) -> io::Result<std::os::fd::OwnedFd> {
self.open_relative(&self.relative(path)?, true)
}
fn open_relative(
&self,
relative: &Path,
final_directory: bool,
) -> io::Result<std::os::fd::OwnedFd> {
use rustix::fs::{Mode, OFlags, openat};
validate_relative_path(relative)?;
let mut directory = rustix::io::dup(&self.directory).map_err(io::Error::from)?;
let components = relative.components().collect::<Vec<_>>();
for (index, component) in components.iter().enumerate() {
let std::path::Component::Normal(name) = component else {
unreachable!("validated relative paths contain only normal components");
};
let final_component = index + 1 == components.len();
let mut flags = OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK;
if !final_component || final_directory {
flags |= OFlags::DIRECTORY;
}
directory = openat(&directory, *name, flags, Mode::empty())
.map_err(|error| project_anchor_error(&self.path.join(relative), error))?;
}
Ok(directory)
}
fn read_optional(&self, path: &Path, limit: usize) -> io::Result<Option<Vec<u8>>> {
match self.open_any(path) {
Ok(descriptor) => read_bounded_regular(descriptor, path, limit).map(Some),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
}
#[cfg(unix)]
impl ProjectRootAnchor {
const GIT_POINTER_LIMIT: usize = 4_096;
pub fn open(root: impl AsRef<Path>) -> io::Result<Self> {
let explicit = DescriptorRoot::open(root.as_ref())?;
let git_marker = explicit.path.join(".git");
let marker = match explicit.open_any(&git_marker) {
Ok(marker) => Some(marker),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) => return Err(error),
};
let Some(marker) = marker else {
return Ok(Self {
primary: explicit,
git_dir: None,
git_common_dir: None,
});
};
let metadata =
fs::File::from(rustix::io::dup(&marker).map_err(io::Error::from)?).metadata()?;
if metadata.is_dir() {
let git_dir = DescriptorRoot::from_directory(git_marker, marker);
return Ok(Self {
primary: explicit,
git_common_dir: Some(git_dir.duplicate()?),
git_dir: Some(git_dir),
});
}
if !metadata.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"project .git marker is neither a directory nor a regular file",
));
}
let pointer = read_bounded_regular(marker, &git_marker, Self::GIT_POINTER_LIMIT)?;
let git_dir_path = parse_git_pointer(&explicit.path, &pointer)?;
let git_dir = DescriptorRoot::open(&git_dir_path)?;
let common_path = match git_dir
.read_optional(&git_dir.path.join("commondir"), Self::GIT_POINTER_LIMIT)?
{
Some(contents) => {
let value = bounded_text_line(&contents, "commondir")?;
Some(normalize_absolute(&git_dir.path, value)?)
}
None => None,
};
let Some(common_path) = common_path else {
return Ok(Self {
primary: explicit,
git_common_dir: Some(git_dir.duplicate()?),
git_dir: Some(git_dir),
});
};
if common_path.file_name().is_none_or(|name| name != ".git") {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"linked worktree common directory does not identify a primary checkout",
));
}
let common = DescriptorRoot::open(&common_path)?;
let primary_path = common_path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"Git common directory has no parent",
)
})?;
let primary = DescriptorRoot::open(primary_path)?;
let primary_git = primary.open_directory(&primary.path.join(".git"))?;
if !same_file(&primary_git, &common.directory)? {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"linked worktree common directory does not match the primary checkout",
));
}
Ok(Self {
primary,
git_dir: Some(git_dir),
git_common_dir: Some(common),
})
}
#[must_use]
pub fn root(&self) -> &Path {
&self.primary.path
}
pub fn filesystem_id(&self) -> io::Result<String> {
let stat = rustix::fs::fstat(&self.primary.directory).map_err(io::Error::from)?;
Ok(format!("unix:{:x}:{:x}", stat.st_dev, stat.st_ino))
}
pub fn open_directory(&self, path: &Path) -> io::Result<std::os::fd::OwnedFd> {
self.primary.open_directory(path)
}
pub fn read_regular(&self, path: &Path, limit: usize) -> io::Result<Vec<u8>> {
let descriptor = self.primary.open_any(path)?;
read_bounded_regular(descriptor, path, limit)
}
fn canonical_descriptor_path(&self, path: &Path) -> io::Result<bool> {
if path.starts_with(&self.primary.path) {
drop(self.primary.open_any(path)?);
return Ok(true);
}
for root in [&self.git_dir, &self.git_common_dir].into_iter().flatten() {
if path == root.path {
drop(root.duplicate()?);
return Ok(true);
}
}
Ok(false)
}
}
#[cfg(unix)]
impl ContextHost for ProjectRootAnchor {
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
if self.canonical_descriptor_path(path)? {
Ok(path.to_path_buf())
} else {
SystemHost.canonicalize(path)
}
}
fn git_rev_parse(&self, cwd: &Path, argument: &str) -> io::Result<PathBuf> {
if cwd != self.primary.path {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"Git discovery escaped the anchored primary project",
));
}
match argument {
"--show-toplevel" if self.git_dir.is_some() => Ok(self.primary.path.clone()),
"--git-common-dir" => self
.git_common_dir
.as_ref()
.map(|root| root.path.clone())
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"anchored project has no Git common directory",
)
}),
"--git-dir" => self
.git_dir
.as_ref()
.map(|root| root.path.clone())
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"anchored project has no Git directory",
)
}),
"--show-toplevel" => Err(io::Error::new(
io::ErrorKind::NotFound,
"anchored project is not a Git checkout",
)),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"unsupported anchored Git query",
)),
}
}
fn read_optional(&self, path: &Path) -> io::Result<Option<String>> {
if !path.starts_with(&self.primary.path) {
return SystemHost.read_optional(path);
}
self.primary
.read_optional(path, 1_048_576)?
.map(|contents| {
String::from_utf8(contents).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"project configuration is not UTF-8",
)
})
})
.transpose()
}
fn symlink_metadata(&self, path: &Path) -> io::Result<fs::Metadata> {
if path.starts_with(&self.primary.path) {
fs::File::from(self.primary.open_any(path)?).metadata()
} else {
SystemHost.symlink_metadata(path)
}
}
}
#[cfg(unix)]
fn validate_absolute_path(path: &Path) -> io::Result<()> {
if !path.is_absolute() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"explicit project root must be absolute",
));
}
if path.components().any(|component| {
!matches!(
component,
std::path::Component::RootDir | std::path::Component::Normal(_)
)
}) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"explicit project root must already be canonical",
));
}
Ok(())
}
#[cfg(unix)]
fn validate_relative_path(path: &Path) -> io::Result<()> {
if path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, std::path::Component::Normal(_)))
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"descriptor-relative path must contain only normal components",
));
}
Ok(())
}
#[cfg(unix)]
fn read_bounded_regular(
descriptor: std::os::fd::OwnedFd,
path: &Path,
limit: usize,
) -> io::Result<Vec<u8>> {
let file = fs::File::from(descriptor);
if !file.metadata()?.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"descriptor target is not a regular file: {}",
path.display()
),
));
}
let mut contents = Vec::new();
file.take(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1))
.read_to_end(&mut contents)?;
if contents.len() > limit {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"descriptor target exceeds {limit} bytes: {}",
path.display()
),
));
}
Ok(contents)
}
#[cfg(unix)]
fn parse_git_pointer(base: &Path, contents: &[u8]) -> io::Result<PathBuf> {
let line = bounded_text_line(contents, ".git")?;
let target = line.strip_prefix("gitdir: ").ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"project .git file has no gitdir pointer",
)
})?;
normalize_absolute(base, target)
}
#[cfg(unix)]
fn bounded_text_line<'a>(contents: &'a [u8], label: &str) -> io::Result<&'a str> {
let value = std::str::from_utf8(contents)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, format!("{label} is not UTF-8")))?
.trim_end_matches(['\r', '\n']);
if value.is_empty() || value.contains(['\r', '\n', '\0']) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{label} must contain one non-empty line"),
));
}
Ok(value)
}
#[cfg(unix)]
fn normalize_absolute(base: &Path, value: &str) -> io::Result<PathBuf> {
let value = Path::new(value);
let candidate = if value.is_absolute() {
value.to_path_buf()
} else {
base.join(value)
};
let mut normalized = PathBuf::from("/");
for component in candidate.components() {
match component {
std::path::Component::RootDir => normalized = PathBuf::from("/"),
std::path::Component::CurDir => {}
std::path::Component::Normal(name) => normalized.push(name),
std::path::Component::ParentDir => {
if !normalized.pop() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Git metadata path escapes the filesystem root",
));
}
}
std::path::Component::Prefix(_) => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Git metadata path has an unsupported prefix",
));
}
}
}
validate_absolute_path(&normalized)?;
Ok(normalized)
}
#[cfg(unix)]
fn same_file(left: &std::os::fd::OwnedFd, right: &std::os::fd::OwnedFd) -> io::Result<bool> {
let left = rustix::fs::fstat(left).map_err(io::Error::from)?;
let right = rustix::fs::fstat(right).map_err(io::Error::from)?;
Ok(left.st_dev == right.st_dev && left.st_ino == right.st_ino)
}
#[cfg(unix)]
fn project_anchor_error(path: &Path, error: rustix::io::Errno) -> io::Error {
match error {
rustix::io::Errno::LOOP => io::Error::other(format!(
"explicit project path contains a symlink: {}",
path.display()
)),
rustix::io::Errno::NOTDIR => io::Error::other(format!(
"explicit project path component is not a directory: {}",
path.display()
)),
_ => io::Error::from(error),
}
}
#[cfg(not(unix))]
#[derive(Debug)]
pub struct ProjectRootAnchor {
root: PathBuf,
}
#[cfg(not(unix))]
impl ProjectRootAnchor {
pub fn open(root: impl AsRef<Path>) -> io::Result<Self> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
format!(
"explicit project binding requires handle-relative reparse-point-safe traversal on this platform: {}",
root.as_ref().display()
),
))
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
pub fn filesystem_id(&self) -> io::Result<String> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"explicit project filesystem identity unavailable",
))
}
pub fn read_regular(&self, path: &Path, _limit: usize) -> io::Result<Vec<u8>> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
format!("explicit project anchor unavailable: {}", path.display()),
))
}
}
#[cfg(not(unix))]
impl ContextHost for ProjectRootAnchor {
fn canonicalize(&self, _path: &Path) -> io::Result<PathBuf> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"explicit project anchor unavailable",
))
}
fn git_rev_parse(&self, _cwd: &Path, _argument: &str) -> io::Result<PathBuf> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"explicit project anchor unavailable",
))
}
fn read_optional(&self, _path: &Path) -> io::Result<Option<String>> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"explicit project anchor unavailable",
))
}
}
fn parse_git_output(output: std::process::Output) -> io::Result<PathBuf> {
if !output.status.success() {
return Err(io::Error::other(
"git rev-parse did not resolve a repository",
));
}
let value = String::from_utf8(output.stdout)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "git returned non-UTF-8"))?;
let value = value.trim();
if value.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"git returned an empty path",
));
}
Ok(PathBuf::from(value))
}
#[derive(Debug, thiserror::Error)]
pub enum ContextError {
#[error("SHEPHERD_HARNESS must name a supported harness")]
InvalidHarness,
#[error("cannot resolve primary repository root: {0}")]
Primary(String),
#[error("cannot read configuration candidate {path}: {source}")]
ReadConfig {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("configuration candidate is not canonical: {0}")]
NonCanonicalCandidate(PathBuf),
#[error("explicit configuration path is not a canonical shepherd candidate: {0}")]
NonCanonicalConfig(PathBuf),
#[error("explicit configuration candidate does not exist: {0}")]
MissingExplicitConfig(PathBuf),
#[error("cannot resolve explicit configuration candidate {path}: {source}")]
ResolveExplicitConfig {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("cannot resolve shepherd user home {path}: {source}")]
ResolveUserHome {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("shepherd user home must not overlap the project namespace")]
UserHomeOverlap,
#[error("{key}: resolved project path is not canonical: {path}")]
NonCanonicalProjectPath { key: &'static str, path: PathBuf },
#[error("cannot resolve {key} project path {path}: {source}")]
ResolveProjectPath {
key: &'static str,
path: PathBuf,
#[source]
source: io::Error,
},
#[error(transparent)]
Config(#[from] shepherd::Error),
}
fn resolve_environment_harness(
environment: &dyn ContextEnvironment,
) -> Result<Option<Harness>, ContextError> {
if let Some(raw) = environment.var_os(OsStr::new("SHEPHERD_HARNESS"))
&& !raw.is_empty()
{
let value = raw.to_str().ok_or(ContextError::InvalidHarness)?.trim();
if !value.is_empty() {
return Harness::from_str(value)
.map(Some)
.map_err(|_| ContextError::InvalidHarness);
}
}
if environment_value_is_present(environment, "CLAUDECODE")
|| environment_value_is_present(environment, "CLAUDE_PLUGIN_ROOT")
{
return Ok(Some(Harness::ClaudeCode));
}
if environment_value_is_present(environment, "CODEX_HOME") {
return Ok(Some(Harness::Codex));
}
Ok(None)
}
fn environment_value_is_present(environment: &dyn ContextEnvironment, key: &str) -> bool {
environment
.var_os(OsStr::new(key))
.is_some_and(|value| !value.is_empty())
}
fn environment_path(environment: &dyn ContextEnvironment, key: &str) -> Option<PathBuf> {
environment
.var_os(OsStr::new(key))
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
pub struct ExecutionContext {
pub workspace_root: PathBuf,
pub primary_root: PathBuf,
pub namespace: PathBuf,
pub docs_root: PathBuf,
pub ctx_root: PathBuf,
pub runs_root: PathBuf,
pub registry_path: PathBuf,
pub registry_lock_path: PathBuf,
pub project_id_path: PathBuf,
pub dups_registry_path: PathBuf,
pub user_home: Option<PathBuf>,
pub active_harness: Option<Harness>,
pub explicit_config: Option<PathBuf>,
pub config: ShepherdConfig,
pub config_sources: Vec<ConfigSource>,
pub explicit_keys: BTreeSet<String>,
pub output_format: OutputFormat,
pub verbosity: u8,
runtime: RuntimeBindings,
}
impl core::fmt::Debug for ExecutionContext {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter
.debug_struct("ExecutionContext")
.field("workspace_root", &self.workspace_root)
.field("primary_root", &self.primary_root)
.field("namespace", &self.namespace)
.field("user_home", &self.user_home)
.field("active_harness", &self.active_harness)
.field("explicit_config", &self.explicit_config)
.field("config_sources", &self.config_sources)
.field("explicit_keys", &self.explicit_keys)
.field("output_format", &self.output_format)
.field("verbosity", &self.verbosity)
.finish_non_exhaustive()
}
}
impl ExecutionContext {
pub fn discover(inputs: ContextInputs) -> Result<Self, ContextError> {
Self::resolve_with(inputs, &SystemHost, RuntimeBindings::system())
}
pub fn discover_for_layout_v5_migration(inputs: ContextInputs) -> Result<Self, ContextError> {
Self::resolve_with_loader(inputs, &SystemHost, RuntimeBindings::system(), true)
}
pub fn resolve_with(
inputs: ContextInputs,
host: &dyn ContextHost,
runtime: RuntimeBindings,
) -> Result<Self, ContextError> {
Self::resolve_with_loader(inputs, host, runtime, false)
}
fn resolve_with_loader(
inputs: ContextInputs,
host: &dyn ContextHost,
runtime: RuntimeBindings,
layout_v5_migration: bool,
) -> Result<Self, ContextError> {
let (primary_root, workspace_root) = resolve_project_roots(&inputs, host)?;
let user_home = resolve_user_home(&inputs, &primary_root, host)?;
let config_context = ConfigContext {
primary_root: primary_root.clone(),
user_home: user_home.clone(),
harness: inputs.active_harness,
};
let candidates = loader::candidates(&config_context);
let explicit_requested = inputs.explicit_config.as_ref().map(|path| {
if path.is_absolute() {
path.clone()
} else {
primary_root.join(path)
}
});
let explicit_config = if let Some(explicit) = explicit_requested {
if !candidates
.iter()
.any(|candidate| candidate.path == explicit)
{
return Err(ContextError::NonCanonicalConfig(explicit));
}
let resolved = match host.canonicalize(&explicit) {
Ok(resolved) => resolved,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Err(ContextError::MissingExplicitConfig(explicit));
}
Err(source) => {
return Err(ContextError::ResolveExplicitConfig {
path: explicit,
source,
});
}
};
if resolved != explicit {
return Err(ContextError::NonCanonicalCandidate(explicit));
}
Some(resolved)
} else {
None
};
let selected: Vec<PathBuf> = if let Some(explicit) = &explicit_config {
vec![explicit.clone()]
} else {
candidates
.into_iter()
.map(|candidate| candidate.path)
.collect()
};
let mut contents = Vec::new();
for path in selected {
let canonical = match host.canonicalize(&path) {
Ok(canonical) if canonical == path => Some(canonical),
Ok(_) => return Err(ContextError::NonCanonicalCandidate(path)),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(source) => {
return Err(ContextError::ReadConfig {
path: path.clone(),
source,
});
}
};
let Some(canonical) = canonical else {
if explicit_config.is_some() {
return Err(ContextError::MissingExplicitConfig(path));
}
continue;
};
match host
.read_optional(&canonical)
.map_err(|source| ContextError::ReadConfig {
path: canonical.clone(),
source,
})? {
Some(contents_value) => contents.push((canonical, contents_value)),
None if explicit_config.is_some() => {
return Err(ContextError::MissingExplicitConfig(canonical));
}
None => {}
}
}
let layers = contents
.iter()
.map(|(path, contents)| (path.as_path(), contents.as_str()));
let loaded = if layout_v5_migration {
loader::load_for_layout_v5_migration(layers)?
} else {
loader::load(layers)?
};
let paths = loaded.config.resolve_paths(&primary_root)?;
validate_resolved_project_paths(host, &paths)?;
Ok(Self {
workspace_root,
primary_root,
namespace: paths.namespace,
docs_root: paths.docs,
ctx_root: paths.ctx,
runs_root: paths.runs,
registry_path: paths.registry,
registry_lock_path: paths.registry_lock,
project_id_path: paths.project_id,
dups_registry_path: paths.dups_registry,
user_home,
active_harness: inputs.active_harness,
explicit_config,
config: loaded.config,
config_sources: loaded.sources,
explicit_keys: loaded.explicit_keys,
output_format: inputs.output_format,
verbosity: inputs.verbosity,
runtime,
})
}
pub fn now_unix_millis(&self) -> i64 {
self.runtime.clock.now_unix_millis()
}
pub fn next_id(&mut self) -> String {
self.runtime.identifiers.next_id()
}
pub fn read_stdin(&mut self, buffer: &mut String) -> io::Result<usize> {
self.runtime.io.read_stdin(buffer)
}
pub fn write_stdout(&mut self, bytes: &[u8]) -> io::Result<()> {
self.runtime.io.write_stdout(bytes)
}
pub fn write_stderr(&mut self, bytes: &[u8]) -> io::Result<()> {
self.runtime.io.write_stderr(bytes)
}
}
fn validate_resolved_project_paths(
host: &dyn ContextHost,
paths: &shepherd::settings::ResolvedPaths,
) -> Result<(), ContextError> {
for (key, path) in [
("namespace", paths.namespace.as_path()),
("paths.docs", paths.docs.as_path()),
("paths.ctx", paths.ctx.as_path()),
("paths.runs", paths.runs.as_path()),
("registry", paths.registry.as_path()),
("registry_lock", paths.registry_lock.as_path()),
("project_id", paths.project_id.as_path()),
("dups.dups_registry", paths.dups_registry.as_path()),
] {
let file_allowed = matches!(
key,
"registry" | "registry_lock" | "project_id" | "dups.dups_registry"
);
validate_resolved_project_path(host, &paths.namespace, key, path, file_allowed)?;
}
Ok(())
}
fn validate_resolved_project_path(
host: &dyn ContextHost,
namespace: &Path,
key: &'static str,
path: &Path,
file_allowed: bool,
) -> Result<(), ContextError> {
if !path.starts_with(namespace) {
return Err(ContextError::NonCanonicalProjectPath {
key,
path: path.to_path_buf(),
});
}
let mut current = path;
loop {
match host.symlink_metadata(current) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(ContextError::NonCanonicalProjectPath {
key,
path: path.to_path_buf(),
});
}
Ok(metadata) if (!file_allowed || current != path) && !metadata.is_dir() => {
return Err(ContextError::NonCanonicalProjectPath {
key,
path: path.to_path_buf(),
});
}
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(source) => {
return Err(ContextError::ResolveProjectPath {
key,
path: path.to_path_buf(),
source,
});
}
}
match host.canonicalize(current) {
Ok(canonical) if canonical == current => return Ok(()),
Ok(_) => {
return Err(ContextError::NonCanonicalProjectPath {
key,
path: path.to_path_buf(),
});
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
if current == namespace {
return Ok(());
}
current =
current
.parent()
.ok_or_else(|| ContextError::NonCanonicalProjectPath {
key,
path: path.to_path_buf(),
})?;
}
Err(source) => {
return Err(ContextError::ResolveProjectPath {
key,
path: path.to_path_buf(),
source,
});
}
}
}
}
fn resolve_project_roots(
inputs: &ContextInputs,
host: &dyn ContextHost,
) -> Result<(PathBuf, PathBuf), ContextError> {
let workspace = host
.git_rev_parse(&inputs.start_dir, "--show-toplevel")
.and_then(|top| host.canonicalize(&top));
let primary = (|| {
let top = host.git_rev_parse(&inputs.start_dir, "--show-toplevel")?;
let common = host.git_rev_parse(&inputs.start_dir, "--git-common-dir")?;
let common = host.canonicalize(&common)?;
let git_dir = host.git_rev_parse(&inputs.start_dir, "--git-dir")?;
let git_dir = host.canonicalize(&git_dir)?;
let primary = if git_dir == common {
top
} else if common.file_name().is_some_and(|name| name == ".git") {
common.parent().map(Path::to_path_buf).unwrap_or(top)
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"linked worktree common directory cannot identify the primary checkout; provide an explicit primary fallback",
));
};
host.canonicalize(&primary)
})();
match (primary, workspace, &inputs.primary_fallback) {
(Ok(primary), Ok(workspace), _) => Ok((primary, workspace)),
(Err(_), Ok(workspace), Some(fallback)) => host
.canonicalize(fallback)
.map(|primary| (primary, workspace))
.map_err(|error| ContextError::Primary(error.to_string())),
(_, Err(_), Some(fallback)) => host
.canonicalize(fallback)
.map(|primary| (primary.clone(), primary))
.map_err(|error| ContextError::Primary(error.to_string())),
(Err(error), _, None) | (_, Err(error), None) => {
Err(ContextError::Primary(error.to_string()))
}
}
}
fn resolve_user_home(
inputs: &ContextInputs,
primary_root: &Path,
host: &dyn ContextHost,
) -> Result<Option<PathBuf>, ContextError> {
let raw = inputs
.shepherd_home
.clone()
.or_else(|| inputs.home_dir.as_ref().map(|home| home.join(".shepherd")));
let Some(raw) = raw else {
return Ok(None);
};
let path = if raw.is_absolute() {
raw
} else {
primary_root.join(raw)
};
let resolved = match host.canonicalize(&path) {
Ok(canonical) => canonical,
Err(error) if error.kind() == io::ErrorKind::NotFound => path,
Err(source) => return Err(ContextError::ResolveUserHome { path, source }),
};
let namespace = primary_root.join(".shepherd");
if resolved.starts_with(&namespace) || namespace.starts_with(&resolved) {
return Err(ContextError::UserHomeOverlap);
}
Ok(Some(resolved))
}
#[derive(Debug)]
struct SystemClock;
impl Clock for SystemClock {
fn now_unix_millis(&self) -> i64 {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
i64::try_from(millis).unwrap_or(i64::MAX)
}
}
#[derive(Debug)]
struct SystemIdentifiers;
impl IdentifierSource for SystemIdentifiers {
fn next_id(&mut self) -> String {
static NEXT: AtomicU64 = AtomicU64::new(0);
let ordinal = NEXT.fetch_add(1, Ordering::Relaxed);
format!("{}-{ordinal}", std::process::id())
}
}
#[derive(Debug)]
struct SystemIo;
impl IoBoundary for SystemIo {
fn read_stdin(&mut self, buffer: &mut String) -> io::Result<usize> {
io::stdin().read_line(buffer)
}
fn write_stdout(&mut self, bytes: &[u8]) -> io::Result<()> {
let mut stdout = io::stdout().lock();
stdout.write_all(bytes)?;
stdout.flush()
}
fn write_stderr(&mut self, bytes: &[u8]) -> io::Result<()> {
let mut stderr = io::stderr().lock();
stderr.write_all(bytes)?;
stderr.flush()
}
}