use std::path::{Component, Path, PathBuf};
pub const CONFIG_DIR_NAME: &str = ".oino";
pub const AGENT_DIR_NAME: &str = ".oino";
pub const SETTINGS_FILE: &str = "settings.json";
pub const KEYBINDINGS_FILE: &str = "keybindings.json";
pub const TRUST_FILE: &str = "trust.json";
pub const SYSTEM_PROMPT_FILE: &str = "SYSTEM.md";
pub const APPEND_SYSTEM_PROMPT_FILE: &str = "APPEND_SYSTEM.md";
pub const LOCK_SUFFIX: &str = ".lock";
pub mod env {
pub const OINO_AGENT_DIR: &str = "OINO_AGENT_DIR";
pub const OINO_SESSION_DIR: &str = "OINO_SESSION_DIR";
pub const OINO_OFFLINE: &str = "OINO_OFFLINE";
pub const OINO_CACHE_RETENTION: &str = "OINO_CACHE_RETENTION";
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum PathError {
#[error("cannot determine the home directory")]
NoHomeDir,
}
#[must_use]
pub fn home_dir() -> Option<PathBuf> {
match std::env::var_os("HOME") {
Some(home) if !home.is_empty() => Some(PathBuf::from(home)),
_ => dirs::home_dir(),
}
}
pub fn agent_dir() -> Result<PathBuf, PathError> {
let env_value = std::env::var(env::OINO_AGENT_DIR).ok();
agent_dir_from(env_value.as_deref(), home_dir().as_deref())
}
pub fn agent_dir_from(env_value: Option<&str>, home: Option<&Path>) -> Result<PathBuf, PathError> {
match env_value {
Some(value) if !value.is_empty() => Ok(normalize_path(value, home)),
_ => home
.map(|home| home.join(AGENT_DIR_NAME))
.ok_or(PathError::NoHomeDir),
}
}
#[must_use]
pub fn project_dir(cwd: &Path) -> PathBuf {
cwd.join(CONFIG_DIR_NAME)
}
#[must_use]
pub fn normalize_path(input: &str, home: Option<&Path>) -> PathBuf {
if let Some(home) = home {
if input == "~" {
return home.to_path_buf();
}
if let Some(rest) = input.strip_prefix("~/") {
return home.join(rest);
}
#[cfg(windows)]
if let Some(rest) = input.strip_prefix("~\\") {
return home.join(rest);
}
}
if let Some(rest) = input.strip_prefix("file://") {
return PathBuf::from(percent_decode(rest));
}
PathBuf::from(input)
}
#[must_use]
pub fn resolve_path(input: &str, base: &Path, home: Option<&Path>, trim: bool) -> PathBuf {
let input = if trim { input.trim() } else { input };
let normalized = normalize_path(input, home);
if normalized.is_absolute() {
lexical_normalize(&normalized)
} else {
lexical_normalize(&base.join(normalized))
}
}
#[must_use]
pub fn lexical_normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() && !path.is_absolute() {
out.push("..");
}
}
other => out.push(other.as_os_str()),
}
}
if out.as_os_str().is_empty() {
out.push(".");
}
out
}
#[must_use]
pub fn canonicalize_or_self(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
#[must_use]
pub fn is_under_path(target: &Path, root: &Path) -> bool {
target.starts_with(root)
}
fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%'
&& let (Some(high), Some(low)) = (
bytes
.get(index + 1)
.and_then(|b| char::from(*b).to_digit(16)),
bytes
.get(index + 2)
.and_then(|b| char::from(*b).to_digit(16)),
)
{
out.push(u8::try_from(high * 16 + low).unwrap_or(b'?'));
index += 3;
continue;
}
out.push(bytes[index]);
index += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_dir_prefers_non_empty_override() {
let home = Path::new("/home/u");
assert_eq!(
agent_dir_from(Some("~/x"), Some(home)).unwrap(),
PathBuf::from("/home/u/x")
);
assert_eq!(
agent_dir_from(Some(""), Some(home)).unwrap(),
PathBuf::from("/home/u/.oino")
);
assert_eq!(agent_dir_from(None, None), Err(PathError::NoHomeDir));
}
#[test]
fn resolve_path_matches_node_resolve() {
let home = Some(Path::new("/h"));
let base = Path::new("/a/b");
assert_eq!(
resolve_path("c/../d", base, home, false),
PathBuf::from("/a/b/d")
);
assert_eq!(
resolve_path(" ~/s ", base, home, true),
PathBuf::from("/h/s")
);
assert_eq!(
resolve_path("/x/./y/..", base, home, false),
PathBuf::from("/x")
);
assert_eq!(
resolve_path("../../..", base, home, false),
PathBuf::from("/")
);
assert_eq!(
resolve_path("file:///p%20q", base, home, false),
PathBuf::from("/p q")
);
}
#[test]
fn normalize_keeps_other_tildes() {
assert_eq!(
normalize_path("~user/x", Some(Path::new("/h"))),
PathBuf::from("~user/x")
);
}
}