Skip to main content

balise_cli/
tools.rs

1//! The machine the CLI runs on: which tools it has, and how to run them. Behind a trait, so
2//! that tests decide what the machine has.
3
4use std::path::Path;
5use std::process::{Command, Stdio};
6use std::time::{Duration, Instant};
7
8/// What the CLI needs to know about, or do on, the machine.
9pub trait Machine {
10    /// Whether a program answers `--version`.
11    fn installed(&self, program: &str) -> bool;
12    /// Whether the Docker daemon answers.
13    fn docker_running(&self) -> bool;
14    /// The operating system, as prompts name it: `macOS`, `Windows`, `Ubuntu 24.04`.
15    fn os(&self) -> String;
16    /// Runs a program in `dir`, its output hidden; `Err` holds what it printed on error.
17    fn run(&self, program: &str, args: &[&str], dir: &Path) -> Result<(), String>;
18    /// Runs a program in `dir` with more environment variables, and returns what it printed.
19    fn output(
20        &self,
21        program: &str,
22        args: &[&str],
23        dir: &Path,
24        variables: &[(String, String)],
25    ) -> Result<String, String>;
26}
27
28/// The real machine.
29pub struct System;
30
31impl Machine for System {
32    fn installed(&self, program: &str) -> bool {
33        Command::new(program)
34            .arg("--version")
35            .stdout(Stdio::null())
36            .stderr(Stdio::null())
37            .status()
38            .is_ok_and(|status| status.success())
39    }
40
41    fn docker_running(&self) -> bool {
42        // `docker info` waits for a daemon that does not answer: a few seconds at most.
43        let Ok(mut child) = Command::new("docker")
44            .arg("info")
45            .stdout(Stdio::null())
46            .stderr(Stdio::null())
47            .spawn()
48        else {
49            return false;
50        };
51        let started = Instant::now();
52        while started.elapsed() < Duration::from_secs(5) {
53            match child.try_wait() {
54                Ok(Some(status)) => return status.success(),
55                Ok(None) => std::thread::sleep(Duration::from_millis(50)),
56                Err(_) => return false,
57            }
58        }
59        let _ = child.kill();
60        false
61    }
62
63    fn os(&self) -> String {
64        match std::env::consts::OS {
65            "macos" => "macOS".into(),
66            "windows" => "Windows".into(),
67            "linux" => linux_distribution().unwrap_or_else(|| "Linux".into()),
68            other => other.into(),
69        }
70    }
71
72    fn run(&self, program: &str, args: &[&str], dir: &Path) -> Result<(), String> {
73        let output = Command::new(program)
74            .args(args)
75            .current_dir(dir)
76            .output()
77            .map_err(|error| error.to_string())?;
78        match output.status.success() {
79            true => Ok(()),
80            false => Err(String::from_utf8_lossy(&output.stderr).trim().to_string()),
81        }
82    }
83
84    fn output(
85        &self,
86        program: &str,
87        args: &[&str],
88        dir: &Path,
89        variables: &[(String, String)],
90    ) -> Result<String, String> {
91        let output = Command::new(program)
92            .args(args)
93            .current_dir(dir)
94            .envs(variables.iter().map(|(name, value)| (name, value)))
95            .output()
96            .map_err(|error| error.to_string())?;
97        match output.status.success() {
98            true => Ok(String::from_utf8_lossy(&output.stdout).into_owned()),
99            false => Err(String::from_utf8_lossy(&output.stderr).trim().to_string()),
100        }
101    }
102}
103
104/// `Ubuntu 24.04`, from `/etc/os-release`.
105fn linux_distribution() -> Option<String> {
106    let release = std::fs::read_to_string("/etc/os-release").ok()?;
107    release.lines().find_map(|line| {
108        let value = line.strip_prefix("PRETTY_NAME=")?;
109        Some(value.trim_matches('"').to_string())
110    })
111}
112
113/// A tool `balise new` checks, and the two prompts given to the agent when it is missing.
114pub struct Tool {
115    pub program: &'static str,
116    pub name: &'static str,
117    /// Why Balise needs it, in a few words.
118    pub why: &'static str,
119}
120
121pub const GIT: Tool = Tool {
122    program: "git",
123    name: "git",
124    why: "to version the project, so that every change can be reviewed and undone",
125};
126
127pub const DOCKER: Tool = Tool {
128    program: "docker",
129    name: "Docker",
130    why: "to run the app, its database and its front end in dev without installing them",
131};
132
133impl Tool {
134    /// A prompt asking the agent how to install the tool on this system.
135    pub fn install_prompt(&self, os: &str) -> String {
136        format!(
137            "I am on {os} and I need {name} for a Balise project, {why}. \
138             Explain how to install it on my system, step by step, and how to check that it works.\n",
139            name = self.name,
140            why = self.why
141        )
142    }
143
144    /// A prompt asking the agent for a short tutorial on the tool.
145    pub fn learn_prompt(&self) -> String {
146        format!(
147            "I am starting a Balise project, which uses {name} {why}. \
148             Give me a short tutorial: what {name} is, why Balise uses it, and the few commands worth knowing.\n",
149            name = self.name,
150            why = self.why
151        )
152    }
153}