Skip to main content

dkdc_sh/
lib.rs

1//! Shell utilities for tmux, git, and command management.
2//!
3//! Minimal, synchronous shell abstractions. No async runtime required.
4
5use std::fmt;
6use std::path::PathBuf;
7use std::process::Command;
8
9pub mod editor;
10pub mod git;
11pub mod tmux;
12
13/// Shell operation errors.
14#[derive(Debug)]
15pub enum Error {
16    CommandNotFound(String),
17    CommandFailed { cmd: String, detail: String },
18    Tmux(String),
19    Io(std::io::Error),
20}
21
22impl fmt::Display for Error {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Error::CommandNotFound(cmd) => write!(f, "command not found: {cmd}"),
26            Error::CommandFailed { cmd, detail } => write!(f, "command failed: {cmd} — {detail}"),
27            Error::Tmux(msg) => write!(f, "tmux error: {msg}"),
28            Error::Io(err) => write!(f, "io error: {err}"),
29        }
30    }
31}
32
33impl std::error::Error for Error {
34    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
35        match self {
36            Error::Io(err) => Some(err),
37            _ => None,
38        }
39    }
40}
41
42impl From<std::io::Error> for Error {
43    fn from(err: std::io::Error) -> Self {
44        Error::Io(err)
45    }
46}
47
48/// Check if a command exists in PATH.
49pub fn which(cmd: &str) -> Option<PathBuf> {
50    ::which::which(cmd).ok()
51}
52
53/// Require a command to exist, returning an error if not found.
54pub fn require(cmd: &str) -> Result<PathBuf, Error> {
55    ::which::which(cmd).map_err(|_| Error::CommandNotFound(cmd.to_string()))
56}
57
58/// Run a command and return its stdout.
59pub fn run(program: &str, args: &[&str]) -> Result<String, Error> {
60    run_with_env(program, args, &[])
61}
62
63/// Run a command with extra environment variables and return its stdout.
64pub fn run_with_env(program: &str, args: &[&str], env: &[(&str, &str)]) -> Result<String, Error> {
65    require(program)?;
66
67    let mut command = Command::new(program);
68    command.args(args);
69    for (k, v) in env {
70        command.env(k, v);
71    }
72    let output = command.output()?;
73
74    if !output.status.success() {
75        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
76        return Err(Error::CommandFailed {
77            cmd: format!("{program} {}", args.first().unwrap_or(&"")),
78            detail: stderr,
79        });
80    }
81
82    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn test_which_exists() {
91        assert!(which("ls").is_some());
92    }
93
94    #[test]
95    fn test_which_not_exists() {
96        assert!(which("nonexistent_command_12345").is_none());
97    }
98
99    #[test]
100    fn test_require_exists() {
101        assert!(require("ls").is_ok());
102    }
103
104    #[test]
105    fn test_require_not_exists() {
106        let result = require("nonexistent_command_12345");
107        assert!(result.is_err());
108        assert!(matches!(result, Err(Error::CommandNotFound(_))));
109    }
110
111    #[test]
112    fn test_run() {
113        let output = run("echo", &["hello"]).unwrap();
114        assert_eq!(output.trim(), "hello");
115    }
116
117    #[test]
118    fn test_run_not_found() {
119        let result = run("nonexistent_command_12345", &[]);
120        assert!(matches!(result, Err(Error::CommandNotFound(_))));
121    }
122
123    #[test]
124    fn test_run_failed_command() {
125        let result = run("ls", &["/nonexistent_path_12345"]);
126        assert!(matches!(result, Err(Error::CommandFailed { .. })));
127    }
128
129    #[test]
130    fn test_run_with_env() {
131        let output = run_with_env("env", &[], &[("DKDC_SH_TEST_VAR", "hello123")]).unwrap();
132        assert!(output.contains("DKDC_SH_TEST_VAR=hello123"));
133    }
134
135    #[test]
136    fn test_error_display() {
137        let err = Error::CommandNotFound("foo".to_string());
138        assert_eq!(err.to_string(), "command not found: foo");
139
140        let err = Error::CommandFailed {
141            cmd: "bar".to_string(),
142            detail: "oops".to_string(),
143        };
144        assert!(err.to_string().contains("bar"));
145
146        let err = Error::Tmux("bad".to_string());
147        assert!(err.to_string().contains("bad"));
148    }
149}