use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use super::BackendState;
use super::confedit::{json_edit, json_obj_at, json_prune_obj, json_remove};
use crate::components::expand_client;
use crate::doctor::{CheckStatus, DoctorCheck};
use crate::error::{Error, Result};
use crate::host::{Plugin, Scope, Source};
use crate::stamp;
use crate::statusline::StatusLineDecl;
#[derive(Clone, Copy)]
pub(crate) enum ValueShape {
TypedCommand,
}
#[derive(Clone, Copy)]
pub(crate) struct SlotShape {
pub(crate) value: ValueShape,
pub(crate) max_rows: Option<u8>,
pub(crate) carry: &'static [&'static str],
pub(crate) disabled_hint: &'static str,
}
impl SlotShape {
pub(crate) const fn typed_command() -> Self {
Self { value: ValueShape::TypedCommand, max_rows: None, carry: &[], disabled_hint: "" }
}
pub(crate) const fn carrying(mut self, keys: &'static [&'static str], hint: &'static str) -> Self {
self.carry = keys;
self.disabled_hint = hint;
self
}
pub(crate) const fn with_max_rows(mut self, rows: u8) -> Self {
self.max_rows = Some(rows);
self
}
}
fn render(decl: &StatusLineDecl, shape: SlotShape) -> Value {
match shape.value {
ValueShape::TypedCommand => {
let mut map = Map::new();
map.insert("type".to_string(), Value::String("command".to_string()));
map.insert("command".to_string(), Value::String(decl.command.clone()));
if let Some(padding) = decl.padding {
map.insert("padding".to_string(), Value::from(padding));
}
if let Some(rows) = shape.max_rows {
map.insert("maxRows".to_string(), Value::from(rows));
}
Value::Object(map)
}
}
}
fn with_carried(ours: &Value, existing: Option<&Value>, shape: SlotShape) -> Value {
let mut out = ours.clone();
if shape.carry.is_empty() {
return out;
}
let Some(existing) = existing else {
return out;
};
if let Some(obj) = out.as_object_mut() {
for key in shape.carry {
if let Some(value) = existing.get(*key) {
obj.insert((*key).to_string(), value.clone());
}
}
}
out
}
fn disabled_carry(existing: &Value, shape: SlotShape) -> Option<&'static str> {
shape.carry.iter().copied().find(|key| existing.get(*key) == Some(&Value::Bool(false)))
}
fn command_of(existing: &Value, shape: SlotShape) -> Option<&str> {
match shape.value {
ValueShape::TypedCommand => existing.get("command").and_then(Value::as_str),
}
}
pub(crate) fn rendered(plugin: &Plugin, client: &str, shape: SlotShape) -> Option<(Value, String)> {
let decl = plugin.statusline.as_ref()?;
let command = expand_client(&decl.command, client);
if command.trim().is_empty() {
return None;
}
Some((render(&StatusLineDecl { command: command.clone(), ..decl.clone() }, shape), command))
}
pub(crate) fn target(
plugin: &Plugin, client: &str, shape: SlotShape, resolve: impl FnOnce() -> Result<PathBuf>,
) -> Result<Option<PathBuf>> {
if rendered(plugin, client, shape).is_none() {
return Ok(None);
}
resolve().map(Some)
}
pub(crate) fn is_ours(existing: &Value, our_command: &str, last_written: Option<&str>, shape: SlotShape) -> bool {
let Some(command) = command_of(existing, shape) else {
return false;
};
command == our_command || last_written == Some(command)
}
fn last_written(marker: Option<&stamp::Marker>) -> Option<&str> {
marker.and_then(|m| m.statusline_command.as_deref())
}
pub(crate) fn reconcile(
path: &Path, key_path: &[&str], plugin: &Plugin, source: &Source, scope: &Scope, client: &str, shape: SlotShape,
) -> Result<bool> {
let Some((ours, our_command)) = rendered(plugin, client, shape) else {
return Ok(false);
};
let Some((containers, slot)) = slot_of(key_path) else {
return Ok(false);
};
let marker = stamp::read(plugin, scope, client)?;
let existing = read_settings(path)?.and_then(|root| value_at(&root, key_path).cloned());
if let Some(displaced) = existing.clone()
&& !is_ours(&displaced, &our_command, last_written(marker.as_ref()), shape)
{
stamp::stash_statusline(plugin, scope, source, client, displaced)?;
}
let carry_source = match &existing {
Some(_) => existing.clone(),
None if !shape.carry.is_empty() => marker.and_then(|m| m.statusline_original),
None => None,
};
let ours = with_carried(&ours, carry_source.as_ref(), shape);
let changed = json_edit(path, |root| {
let obj = json_obj_at(root, containers);
if obj.get(slot) != Some(&ours) {
obj.insert(slot.to_string(), ours.clone());
}
Ok(())
})?;
stamp::record_statusline_command(plugin, scope, source, client, &our_command)?;
Ok(changed)
}
pub(crate) fn remove(path: &Path, key_path: &[&str], plugin: &Plugin, scope: &Scope, client: &str, shape: SlotShape) -> Result<bool> {
let Some((_, our_command)) = rendered(plugin, client, shape) else {
return Ok(false);
};
let Some((containers, slot)) = slot_of(key_path) else {
return Ok(false);
};
let marker = stamp::read(plugin, scope, client)?;
let last = last_written(marker.as_ref()).map(str::to_string);
let stashed = marker.and_then(|m| m.statusline_original);
json_remove(path, |root| {
json_prune_obj(root, containers, |obj| {
if !obj.get(slot).is_some_and(|existing| is_ours(existing, &our_command, last.as_deref(), shape)) {
return Ok(());
}
match &stashed {
Some(original) => obj.insert(slot.to_string(), original.clone()),
None => obj.remove(slot),
};
Ok(())
})
.map(|_| ())
})
}
pub(crate) fn state(
path: &Path, key_path: &[&str], plugin: &Plugin, scope: &Scope, client: &str, shape: SlotShape,
) -> Result<Option<BackendState>> {
let Some((ours, our_command)) = rendered(plugin, client, shape) else {
return Ok(None);
};
let marker = stamp::read(plugin, scope, client)?;
let root = read_settings(path)?;
Ok(Some(match root.as_ref().and_then(|r| value_at(r, key_path)) {
None => BackendState::Absent,
Some(existing) if *existing == with_carried(&ours, Some(existing), shape) => BackendState::Healthy,
Some(existing) if is_ours(existing, &our_command, last_written(marker.as_ref()), shape) => BackendState::NeedsRepair,
Some(_) => BackendState::Absent,
}))
}
pub(crate) fn read_settings(path: &Path) -> Result<Option<Value>> {
match fs::read(path) {
Ok(bytes) => Ok(serde_json::from_slice(&bytes).ok()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(Error::Io { context: format!("reading {}", path.display()), source }),
}
}
pub(crate) fn check(
key_path: &[&str], plugin: &Plugin, scope: &Scope, client: &str, shape: SlotShape, harness: &str,
resolve: impl FnOnce(&Scope) -> Result<PathBuf>,
) -> Option<DoctorCheck> {
let name = "status line installed";
let (ours, our_command) = rendered(plugin, client, shape)?;
let slot = key_path.join(".");
let path = match resolve(scope) {
Ok(path) => path,
Err(Error::EmptyConfigDirOverride { var }) => {
return Some(DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("`{var}` is set to an empty string, so {harness}'s config dir cannot be resolved"),
fix: format!("unset `{var}` or point it at a real directory"),
},
});
}
Err(e) => return Some(DoctorCheck { name, status: CheckStatus::Warn(format!("could not locate {harness}'s settings: {e}")) }),
};
let marker = stamp::read(plugin, scope, client).ok().flatten();
let last = last_written(marker.as_ref());
let root = read_settings(&path).ok().flatten();
Some(match root.as_ref().and_then(|r| value_at(r, key_path)) {
Some(existing) if *existing == with_carried(&ours, Some(existing), shape) => match disabled_carry(existing, shape) {
Some(key) => DoctorCheck {
name,
status: CheckStatus::Warn(format!(
"`{}` owns the {slot} slot, but `{key}` is false there: {harness}'s own status-line switch is off, so none of it renders. {}",
plugin.name, shape.disabled_hint
)),
},
None => DoctorCheck { name, status: CheckStatus::Ok(format!("`{}` owns the {slot} slot", plugin.name)) },
},
Some(existing) if is_ours(existing, &our_command, last, shape) => DoctorCheck {
name,
status: CheckStatus::Warn(format!(
"`{}` owns the {slot} slot in {}, but its value has drifted from what this version writes",
plugin.name,
path.display()
)),
},
Some(_) => DoctorCheck {
name,
status: CheckStatus::Warn(format!(
"another status line owns `{slot}` in {}; the slot holds one value, so ours is not shown",
path.display()
)),
},
None => DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("no `{slot}` in {}", path.display()),
fix: "run the host binary's `setup` (or `install`) subcommand".into(),
},
},
})
}
fn slot_of<'a>(key_path: &'a [&'a str]) -> Option<(&'a [&'a str], &'a str)> {
debug_assert!(!key_path.is_empty(), "a status-line key path must name its slot key");
let (slot, containers) = key_path.split_last()?;
Some((containers, slot))
}
fn value_at<'a>(root: &'a Value, key_path: &[&str]) -> Option<&'a Value> {
let mut cur = root;
for key in key_path {
cur = cur.get(key)?;
}
Some(cur)
}
#[cfg(test)]
#[path = "../../tests/unit/statuslinejson.rs"]
mod statuslinejson_tests;