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