use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, anyhow, bail};
use argonauts_core::layout::{project_files, repo_state};
use argonauts_core::paths::{project_config_path, project_local_config_path};
use argonauts_core::{Paths, RepoId, RunId};
use crate::{git, hidden_git};
pub(crate) const SESSION_ENV: &str = "ARGONAUTS_SESSION";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FolderGit {
Repository,
Hidden(PathBuf),
Off,
}
impl FolderGit {
pub(crate) fn has_history(&self) -> bool {
!matches!(self, Self::Off)
}
}
#[derive(Debug, Clone)]
pub(crate) struct AppEnv {
pub paths: Paths,
pub repo_root: PathBuf,
pub repo_id: RepoId,
pub state_dir: PathBuf,
pub interactive: bool,
pub agent_session: bool,
pub user_home: Option<PathBuf>,
}
impl AppEnv {
pub(crate) fn discover(dir: Option<&Path>) -> anyhow::Result<Self> {
let paths = Paths::from_env()
.map_err(|_| anyhow!("can't find your home directory; set ARGONAUTS_HOME"))?;
let start = match dir {
Some(dir) => dir.to_owned(),
None => std::env::current_dir().context("reading the current directory")?,
};
if !start.is_dir() {
bail!("{} isn't a folder", start.display());
}
let root = git::project_root(&start);
let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
Self::for_repo(paths, &root, interactive)
}
pub(crate) fn for_repo(
paths: Paths,
repo_root: &Path,
interactive: bool,
) -> anyhow::Result<Self> {
let repo_root = repo_root
.canonicalize()
.with_context(|| format!("opening {}", repo_root.display()))?;
let repo_id = RepoId::for_repo(&repo_root)?;
let state_dir = paths.state_dir(&repo_id);
git::know_folder(&repo_root, &hidden_git::dir(&state_dir));
Ok(Self {
paths,
repo_root,
repo_id,
state_dir,
interactive,
agent_session: std::env::var_os(SESSION_ENV).is_some(),
user_home: std::env::var_os("HOME").map(PathBuf::from),
})
}
pub(crate) fn runs_dir(&self) -> PathBuf {
self.state_dir.join(repo_state::RUNS)
}
pub(crate) fn run_dir(&self, run: &RunId) -> PathBuf {
self.paths.run_dir(&self.repo_id, run)
}
pub(crate) fn conversation_dir(&self) -> PathBuf {
self.state_dir.join(repo_state::CONVERSATION)
}
pub(crate) fn global_config(&self) -> PathBuf {
self.paths.global_config()
}
pub(crate) fn git(&self) -> FolderGit {
let hidden = hidden_git::dir(&self.state_dir);
if self.repo_root.join(".git").exists() {
FolderGit::Repository
} else if hidden_git::is_on(&hidden) {
FolderGit::Hidden(hidden)
} else {
FolderGit::Off
}
}
pub(crate) fn project_config(&self) -> PathBuf {
let shared = project_config_path(&self.repo_root);
let own = self.global_config();
let folder = |file: &Path| file.parent().and_then(|dir| dir.canonicalize().ok());
if folder(&shared).is_some_and(|dir| Some(dir) == folder(&own)) {
return self.state_dir.join(project_files::CONFIG);
}
shared
}
pub(crate) fn project_local_config(&self) -> PathBuf {
if self.git() == FolderGit::Repository {
project_local_config_path(&self.repo_root)
} else {
self.state_dir.join(project_files::CONFIG_LOCAL)
}
}
pub(crate) fn display(&self, path: &Path) -> String {
if let Some(home) = &self.user_home
&& let Ok(rest) = path.strip_prefix(home)
{
if rest.as_os_str().is_empty() {
return "~".to_owned();
}
return format!("~/{}", rest.display());
}
path.display().to_string()
}
pub(crate) fn shown(&self, path: &Path) -> String {
match path.strip_prefix(&self.repo_root) {
Ok(relative) if !relative.as_os_str().is_empty() => relative.display().to_string(),
_ => self.display(path),
}
}
pub(crate) fn shown_in(&self, text: &str) -> String {
let root = format!("{}/", self.repo_root.display());
if root == "/" {
return text.to_owned();
}
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find(&root) {
let (before, after) = rest.split_at(at);
out.push_str(before);
let starts_a_path = before
.chars()
.next_back()
.is_none_or(|c| !(c.is_alphanumeric() || matches!(c, '/' | '.' | '-' | '_')));
if !starts_a_path {
out.push_str(&root);
}
rest = &after[root.len()..];
}
out.push_str(rest);
out
}
pub(crate) fn display_repo(&self) -> String {
self.display(&self.repo_root)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn files_in_the_repo_are_named_from_the_repo() {
let repo = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let mut env = AppEnv::for_repo(Paths::new(home.path()), repo.path(), false).unwrap();
let root = env.repo_root.clone();
let outside = root.parent().unwrap().to_owned();
env.user_home = Some(outside.clone());
assert_eq!(
env.shown(&root.join(".argonauts/config.toml")),
".argonauts/config.toml"
);
let name = root.file_name().unwrap().to_string_lossy();
assert_eq!(env.shown(&root), format!("~/{name}"));
assert_eq!(env.shown(&outside.join("x.toml")), "~/x.toml");
let root = root.display();
assert_eq!(
env.shown_in(&format!(
"invalid config in {root}/.argonauts/config.local.toml: see `{root}/a.rs` and \
/x{root}/b.rs"
)),
format!("invalid config in .argonauts/config.local.toml: see `a.rs` and /x{root}/b.rs")
);
}
#[test]
fn home_shows_as_a_tilde() {
let repo = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let mut env = AppEnv::for_repo(Paths::new(home.path()), repo.path(), false).unwrap();
env.user_home = Some(PathBuf::from("/Users/you"));
assert_eq!(env.display(Path::new("/Users/you/code/app")), "~/code/app");
assert_eq!(env.display(Path::new("/tmp/x")), "/tmp/x");
}
#[test]
fn paths_under_argonauts_home() {
let repo = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let env = AppEnv::for_repo(Paths::new(home.path()), repo.path(), false).unwrap();
let run = RunId::new("run-03").unwrap();
for path in [
env.state_dir.clone(),
env.runs_dir(),
env.run_dir(&run),
env.conversation_dir(),
env.global_config(),
env.paths.global_workflows(),
] {
assert!(path.starts_with(home.path()), "{}", path.display());
assert!(!path.starts_with(&env.repo_root), "{}", path.display());
}
assert!(env.run_dir(&run).starts_with(env.runs_dir()));
assert!(env.project_config().starts_with(&env.repo_root));
}
#[test]
fn a_repository_keeps_your_own_settings_in_it_and_a_plain_folder_with_its_state() {
let repo = argonauts_testkit::TempRepo::new().unwrap();
let env = AppEnv::for_repo(Paths::new(repo.home()), repo.root(), false).unwrap();
assert_eq!(env.git(), FolderGit::Repository);
assert!(env.project_local_config().starts_with(&env.repo_root));
let folder = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let env = AppEnv::for_repo(Paths::new(home.path()), folder.path(), false).unwrap();
assert_eq!(env.git(), FolderGit::Off);
assert!(!env.git().has_history());
assert!(env.project_config().starts_with(&env.repo_root));
assert!(env.project_local_config().starts_with(&env.state_dir));
}
#[test]
fn started_in_your_home_the_project_settings_are_not_your_own_file() {
let account = tempfile::tempdir().unwrap();
let paths = Paths::new(account.path().join(".argonauts"));
std::fs::create_dir_all(paths.home()).unwrap();
let env = AppEnv::for_repo(paths, account.path(), false).unwrap();
assert_ne!(env.project_config(), env.global_config());
assert!(env.project_config().starts_with(&env.state_dir));
}
#[test]
fn any_folder_is_a_project_and_a_repository_is_one_from_its_top() {
let repo = argonauts_testkit::TempRepo::new().unwrap();
let top = repo.root().canonicalize().unwrap();
let inside = top.join("src/deep");
std::fs::create_dir_all(&inside).unwrap();
assert_eq!(git::project_root(&inside), top);
repo.write(".gitignore", "scratch/\n").unwrap();
let scratch = top.join("scratch");
std::fs::create_dir_all(&scratch).unwrap();
assert_eq!(git::project_root(&scratch), scratch);
let env = AppEnv::for_repo(Paths::new(repo.home()), &scratch, false).unwrap();
assert_eq!(env.git(), FolderGit::Off);
assert!(
!git::has_git(&env.repo_root),
"git must not walk up into the repository that ignores the folder"
);
assert!(git::has_git(&top));
let plain = tempfile::tempdir().unwrap();
let plain = plain.path().canonicalize().unwrap();
assert_eq!(git::project_root(&plain), plain);
}
}