use std::process::Command;
use crate as cindy;
use crate::Context;
#[derive(Clone, PartialEq, Eq)]
#[crate::wire]
pub enum Presence {
Absent,
Purged,
Present,
Version(String),
}
#[derive(Clone, Default, PartialEq, Eq)]
#[crate::wire]
pub struct State {
pub name: String,
pub presence: Option<Presence>,
pub hold: Option<bool>,
}
impl crate::Diff for State {}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum InstallStatus {
Installed,
ConfigFiles,
NotInstalled,
}
struct CurrentState {
install_status: InstallStatus,
version: Option<String>,
held: bool,
}
use crate::builtin::run_check;
fn apt_get() -> Command {
let mut c = Command::new("apt-get");
c.env("DEBIAN_FRONTEND", "noninteractive");
c.arg("-y");
c
}
fn read_current_state(pkg: &str) -> crate::Result<CurrentState> {
let dpkg = Command::new("dpkg-query")
.args(["-W", "-f", "${db:Status-Abbrev}|${Version}", "--", pkg])
.output()
.context("Failed to spawn `dpkg-query`")?;
let (install_status, version) = if dpkg.status.success() {
let raw = String::from_utf8_lossy(&dpkg.stdout);
let (abbrev, ver) = raw.split_once('|').unwrap_or((raw.as_ref(), ""));
let current = abbrev.chars().nth(1).unwrap_or(' ');
let status = match current {
'i' => InstallStatus::Installed,
'c' => InstallStatus::ConfigFiles,
_ => InstallStatus::NotInstalled,
};
let version = matches!(status, InstallStatus::Installed).then(|| ver.trim().to_owned());
(status, version)
} else {
(InstallStatus::NotInstalled, None)
};
let mark = Command::new("apt-mark")
.arg("showhold")
.output()
.context("Failed to spawn `apt-mark showhold`")?;
if !mark.status.success() {
crate::bail!(
"`apt-mark showhold` failed:\n{}",
String::from_utf8_lossy(&mark.stderr)
);
}
let held = String::from_utf8_lossy(&mark.stdout)
.lines()
.any(|line| line.trim() == pkg);
Ok(CurrentState {
install_status,
version,
held,
})
}
fn current_to_presence(cur: &CurrentState) -> Presence {
match cur.install_status {
InstallStatus::Installed => match &cur.version {
Some(v) => Presence::Version(v.clone()),
None => Presence::Present,
},
InstallStatus::ConfigFiles => Presence::Absent,
InstallStatus::NotInstalled => Presence::Purged,
}
}
fn dpkg_versions_equal(have: &str, want: &str) -> bool {
Command::new("dpkg")
.args(["--compare-versions", have, "eq", want])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn presence_satisfied(cur: &CurrentState, desired: &Presence) -> bool {
match desired {
Presence::Absent => matches!(
cur.install_status,
InstallStatus::ConfigFiles | InstallStatus::NotInstalled
),
Presence::Purged => matches!(cur.install_status, InstallStatus::NotInstalled),
Presence::Present => matches!(cur.install_status, InstallStatus::Installed),
Presence::Version(v) => {
cur.install_status == InstallStatus::Installed
&& cur
.version
.as_deref()
.is_some_and(|have| dpkg_versions_equal(have, v))
}
}
}
#[crate::remote]
pub fn apt(state: State) -> crate::Result<crate::builtin::Return> {
apply(state)
}
#[crate::action]
pub fn install(name: impl Into<String>) -> crate::Result<crate::builtin::Return> {
apply(State {
name,
presence: Some(Presence::Present),
hold: None,
})
}
#[crate::action]
pub fn install_version(
name: impl Into<String>,
version: impl Into<String>,
) -> crate::Result<crate::builtin::Return> {
apply(State {
name,
presence: Some(Presence::Version(version)),
hold: None,
})
}
#[crate::action]
pub fn hold(
name: impl Into<String>,
version: impl Into<String>,
) -> crate::Result<crate::builtin::Return> {
apply(State {
name,
presence: Some(Presence::Version(version)),
hold: Some(true),
})
}
#[crate::action]
pub fn remove(name: impl Into<String>) -> crate::Result<crate::builtin::Return> {
apply(State {
name,
presence: Some(Presence::Absent),
hold: None,
})
}
#[crate::action]
pub fn purge(name: impl Into<String>) -> crate::Result<crate::builtin::Return> {
apply(State {
name,
presence: Some(Presence::Purged),
hold: None,
})
}
fn apply(state: State) -> crate::Result<crate::builtin::Return> {
let cur = read_current_state(&state.name)?;
let old_view = State {
name: state.name.clone(),
presence: Some(current_to_presence(&cur)),
hold: Some(cur.held),
};
let new_view = State {
name: state.name.clone(),
presence: Some(
state
.presence
.clone()
.unwrap_or_else(|| current_to_presence(&cur)),
),
hold: Some(state.hold.unwrap_or(cur.held)),
};
if old_view != new_view {
let _ = <State as crate::Diff>::diff(&old_view, &new_view, &mut std::io::stderr().lock());
}
let mut changed = false;
let mut effective_held = cur.held;
let presence_needs_change = state
.presence
.as_ref()
.is_some_and(|p| !presence_satisfied(&cur, p));
let final_held = state.hold.unwrap_or(effective_held);
if effective_held && (state.hold == Some(false) || presence_needs_change) {
run_check(Command::new("apt-mark").args(["unhold", "--", &state.name]))?;
effective_held = false;
changed = true;
}
if let Some(presence) = state.presence.as_ref()
&& !presence_satisfied(&cur, presence)
{
match presence {
Presence::Absent => {
run_check(apt_get().args(["remove", "--", &state.name]))?;
}
Presence::Purged => {
run_check(apt_get().args(["purge", "--", &state.name]))?;
}
Presence::Present => {
run_check(apt_get().args(["install", "--", &state.name]))?;
}
Presence::Version(v) => {
let spec = format!("{}={}", state.name, v);
run_check(apt_get().args(["install", "--", &spec]))?;
}
}
changed = true;
}
if effective_held != final_held {
let subcmd = if final_held { "hold" } else { "unhold" };
run_check(Command::new("apt-mark").args([subcmd, "--", &state.name]))?;
changed = true;
}
Ok(crate::builtin::Return::from_changed(changed))
}