Skip to content

Commit 621668a

Browse files
feat(hm-common): process module — capture + pathbin (#171)
1 parent f43d8f8 commit 621668a

21 files changed

Lines changed: 1198 additions & 254 deletions

File tree

‎Cargo.lock‎

Lines changed: 10 additions & 48 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/hm-common/Cargo.toml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,18 @@ categories = ["command-line-utilities"]
1111
[lib]
1212
path = "src/lib.rs"
1313

14+
[features]
15+
app-runtime = []
16+
1417
[dependencies]
1518
anyhow = { workspace = true }
1619
async-trait = { workspace = true }
1720
chrono = { workspace = true }
1821
derive_more = { workspace = true }
1922
serde = { workspace = true }
2023
serde_json = { workspace = true }
24+
thiserror = { workspace = true }
25+
bstr = "1"
2126
include_dir = "0.7"
2227
num-traits = { workspace = true }
2328
tempfile = "3"
@@ -30,7 +35,6 @@ which = "7"
3035
[dev-dependencies]
3136
tempfile = "3"
3237
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
33-
which = "7"
3438
rstest = { workspace = true }
3539
proptest = { workspace = true }
3640

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//! Application runtime context.
2+
3+
use std::path::{Path, PathBuf};
4+
use std::sync::OnceLock;
5+
6+
use crate::git::Git;
7+
use crate::process::{ExecutableNotFound, pathbin};
8+
use crate::python::Python;
9+
10+
/// Failure to initialize the [`AppRuntime`].
11+
#[derive(Debug, thiserror::Error)]
12+
pub enum InitError {
13+
/// A required executable was missing from `PATH`.
14+
#[error(transparent)]
15+
Executables(#[from] ExecutableNotFound),
16+
/// The current directory could not be read.
17+
#[error("reading the current directory")]
18+
Cwd(#[source] std::io::Error),
19+
}
20+
21+
// TODO: This is a process-wide singleton (a global `OnceLock`), which is
22+
// convenient but couples every caller to hidden global state. Consider
23+
// threading an explicit `AppRuntime` handle through the application instead,
24+
// so dependencies are visible in signatures and tests can inject their own.
25+
/// Process-wide runtime context, resolved once at startup.
26+
///
27+
/// Install it with [`init`](Self::init), then read it from anywhere through the
28+
/// associated accessors — no threading required.
29+
#[derive(Debug)]
30+
pub struct AppRuntime {
31+
git: PathBuf,
32+
python3: PathBuf,
33+
cwd: PathBuf,
34+
}
35+
36+
static RUNTIME: OnceLock<AppRuntime> = OnceLock::new();
37+
38+
impl AppRuntime {
39+
/// Resolve the runtime and install it as the process-wide singleton.
40+
///
41+
/// Call once, early in `main`, before any accessor. A later call is ignored.
42+
///
43+
/// # Errors
44+
/// [`InitError`] if a required executable is missing from `PATH` or the
45+
/// current directory cannot be read.
46+
pub fn init() -> Result<(), InitError> {
47+
let runtime = Self::resolve()?;
48+
let _ = RUNTIME.set(runtime);
49+
Ok(())
50+
}
51+
52+
fn resolve() -> Result<Self, InitError> {
53+
Ok(Self {
54+
git: pathbin("git")?,
55+
python3: pathbin("python3")?,
56+
cwd: std::env::current_dir().map_err(InitError::Cwd)?,
57+
})
58+
}
59+
60+
#[allow(
61+
clippy::expect_used,
62+
reason = "accessing the runtime before init is a startup bug, not a runtime error"
63+
)]
64+
fn get() -> &'static Self {
65+
RUNTIME
66+
.get()
67+
.expect("AppRuntime::init must be called before the runtime is accessed")
68+
}
69+
70+
/// The absolute working directory captured at initialization.
71+
///
72+
/// # Panics
73+
/// If [`init`](Self::init) has not been called.
74+
#[must_use]
75+
pub fn cwd() -> &'static Path {
76+
&Self::get().cwd
77+
}
78+
79+
/// The system `git`, bound to a [`Git`] handle.
80+
///
81+
/// # Panics
82+
/// If [`init`](Self::init) has not been called.
83+
#[must_use]
84+
pub fn git() -> Git<'static> {
85+
Git::new(&Self::get().git)
86+
}
87+
88+
/// The system `python3`, bound to a [`Python`] handle.
89+
///
90+
/// # Panics
91+
/// If [`init`](Self::init) has not been called.
92+
#[must_use]
93+
pub fn python() -> Python<'static> {
94+
Python::new(&Self::get().python3)
95+
}
96+
}
97+
98+
#[cfg(test)]
99+
#[allow(
100+
clippy::unwrap_used,
101+
clippy::print_stderr,
102+
reason = "test setup, assertions, and skip diagnostics"
103+
)]
104+
mod tests {
105+
use super::*;
106+
use rstest::rstest;
107+
108+
#[rstest]
109+
fn resolve_reports_toolchain_availability() {
110+
assert_eq!(
111+
AppRuntime::resolve().is_ok(),
112+
pathbin("python3").is_ok() && pathbin("git").is_ok()
113+
);
114+
}
115+
116+
#[rstest]
117+
fn resolve_captures_an_absolute_cwd() {
118+
let Ok(runtime) = AppRuntime::resolve() else {
119+
eprintln!("skipping: toolchain unavailable");
120+
return;
121+
};
122+
assert!(runtime.cwd.is_absolute(), "cwd was {:?}", runtime.cwd);
123+
}
124+
125+
#[rstest]
126+
fn init_installs_a_globally_accessible_runtime() {
127+
if AppRuntime::resolve().is_err() {
128+
eprintln!("skipping: toolchain unavailable");
129+
return;
130+
}
131+
AppRuntime::init().unwrap();
132+
assert!(AppRuntime::cwd().is_absolute());
133+
// git() binds the resolved git; a fresh temp dir is not a repo.
134+
let dir = tempfile::tempdir().unwrap();
135+
assert!(AppRuntime::git().repo(dir.path()).is_err());
136+
}
137+
138+
#[rstest]
139+
fn init_error_wraps_a_missing_executable() {
140+
let err: InitError = pathbin("hm-common-no-such-binary-xyz").unwrap_err().into();
141+
assert!(matches!(err, InitError::Executables(_)));
142+
}
143+
}

0 commit comments

Comments
 (0)