use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command as ProcessCommand;
use anyhow::{Context, Result};
use crate::db::Database;
use super::manage::{build_context, conflict_message, ensure_supported, select_provider};
use super::model::*;
use super::render::render_retention_value;
pub(crate) fn status_report(db_path: &Path) -> Result<ServiceStatusReport> {
ensure_supported()?;
let context = build_context(db_path)?;
let direct_row = launchctl_row(DIRECT_LABEL)?;
let homebrew_row = launchctl_row(HOMEBREW_LABEL)?;
let direct_installed = context.direct_plist_path.is_file() || direct_row.is_some();
let homebrew_installed = context.homebrew_plist_path.is_file() || homebrew_row.is_some();
let direct_status = provider_status(ProviderStatusInput {
provider: ServiceProvider::Launchagent,
label: DIRECT_LABEL,
installed: direct_installed,
row: direct_row,
plist_path: Some(context.direct_plist_path.clone()),
configured_binary_path: configured_binary_path_from_plist(&context.direct_plist_path),
stdout_log_path: Some(context.direct_stdout_path.clone()),
stderr_log_path: Some(context.direct_stderr_path.clone()),
});
let homebrew_status = provider_status(ProviderStatusInput {
provider: ServiceProvider::Homebrew,
label: HOMEBREW_LABEL,
installed: homebrew_installed,
row: homebrew_row,
plist_path: Some(context.homebrew_plist_path.clone()),
configured_binary_path: configured_binary_path_from_plist(&context.homebrew_plist_path),
stdout_log_path: context
.homebrew_prefix
.as_ref()
.map(|prefix| prefix.join("var/log/clipmem.log")),
stderr_log_path: context
.homebrew_prefix
.as_ref()
.map(|prefix| prefix.join("var/log/clipmem.error.log")),
});
let conflict = homebrew_status.installed && direct_status.installed;
let selection = select_provider(&context);
let db_exists = context.db_path.is_file();
let db_size_bytes = if db_exists {
fs::metadata(&context.db_path)
.map(|metadata| metadata.len())
.ok()
} else {
None
};
let (
recent_capture_at,
recent_capture_within_last_hour,
paused,
api_key_filter_enabled,
retention_seconds,
retention,
ignored_bundle_id_count,
db_error,
) = if db_exists {
match Database::open_existing(&context.db_path).and_then(|db| {
let policy = db.capture_policy()?;
Ok((
db.latest_capture_observed_at()?,
Some(db.has_capture_within_hours(SERVICE_FRESHNESS_HOURS)?),
Some(policy.settings().paused()),
Some(policy.settings().api_key_filter_enabled()),
policy.settings().retention_seconds(),
Some(render_retention_value(policy.settings())),
Some(policy.ignored_bundle_id_count()),
))
}) {
Ok(fields) => (
fields.0, fields.1, fields.2, fields.3, fields.4, fields.5, fields.6, None,
),
Err(error) => (
None,
None,
None,
None,
None,
None,
None,
Some(error.to_string()),
),
}
} else {
(None, None, None, None, None, None, None, None)
};
let stale = matches!(recent_capture_within_last_hour, Some(false))
&& !homebrew_status.running
&& !direct_status.running;
let mut notes = selection.notes;
if conflict {
notes.push(conflict_message());
}
if !db_exists {
notes.push(format!(
"Database does not exist yet at {}. Run `clipmem setup` to initialize capture.",
context.db_path.display()
));
}
if stale {
notes.push(
"No recent captures were found and no background watcher is running.".to_string(),
);
}
let watcher_binary_mismatch_note =
watcher_binary_mismatch_note(&context.binary_path, [&homebrew_status, &direct_status]);
let watcher_binary_mismatch = watcher_binary_mismatch_note.is_some();
if let Some(note) = &watcher_binary_mismatch_note {
notes.push(note.clone());
}
Ok(ServiceStatusReport {
binary_path: context.binary_path.display().to_string(),
db_path: context.db_path.display().to_string(),
preferred_provider: selection.provider.as_str().to_string(),
preferred_provider_reason: selection.reason,
conflict,
homebrew: homebrew_status,
launchagent: direct_status,
db_exists,
db_size_bytes,
recent_capture_at,
recent_capture_within_last_hour,
paused,
api_key_filter_enabled,
retention_seconds,
retention,
ignored_bundle_id_count,
stale,
db_error,
watcher_binary_mismatch,
watcher_binary_mismatch_note,
notes,
})
}
pub(in crate::cli) fn launchctl_row(label: &str) -> Result<Option<LaunchctlRow>> {
let output = ProcessCommand::new("launchctl")
.arg("list")
.output()
.context("run launchctl list")?;
if !output.status.success() {
return Ok(None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let mut parts = line.split_whitespace();
let Some(pid) = parts.next() else {
continue;
};
let _last_exit = parts.next();
let Some(found_label) = parts.next() else {
continue;
};
if found_label != label {
continue;
}
return Ok(Some(LaunchctlRow {
pid: if pid == "-" {
None
} else {
pid.parse::<i64>().ok()
},
}));
}
Ok(None)
}
pub(in crate::cli) struct ProviderStatusInput {
provider: ServiceProvider,
label: &'static str,
installed: bool,
row: Option<LaunchctlRow>,
plist_path: Option<PathBuf>,
configured_binary_path: Option<String>,
stdout_log_path: Option<PathBuf>,
stderr_log_path: Option<PathBuf>,
}
pub(in crate::cli) fn provider_status(input: ProviderStatusInput) -> ServiceProviderStatus {
let ProviderStatusInput {
provider,
label,
installed,
row,
plist_path,
configured_binary_path,
stdout_log_path,
stderr_log_path,
} = input;
let (loaded, running, pid) = match row {
Some(row) if row.pid.is_some() => (true, true, row.pid),
Some(_) => (true, false, None),
None => (false, false, None),
};
let running_command = pid.and_then(process_command);
let running_binary_path = running_command.as_deref().and_then(command_binary_path);
let state = if running {
ServiceState::Running
} else if loaded {
ServiceState::Loaded
} else if installed {
ServiceState::Installed
} else {
ServiceState::NotInstalled
};
ServiceProviderStatus {
provider,
label: label.to_string(),
state,
installed,
loaded,
running,
pid,
plist_path: plist_path.map(|path| path.display().to_string()),
configured_binary_path,
running_command,
running_binary_path,
stdout_log_path: stdout_log_path.map(|path| path.display().to_string()),
stderr_log_path: stderr_log_path.map(|path| path.display().to_string()),
}
}
pub(crate) fn configured_binary_path_from_plist(plist_path: &Path) -> Option<String> {
if !plist_path.is_file() {
return None;
}
let Some(output) = ProcessCommand::new("plutil")
.args([
"-extract",
"ProgramArguments.0",
"raw",
"-o",
"-",
&plist_path.display().to_string(),
])
.output()
.ok()
else {
return configured_binary_path_from_plist_xml(plist_path);
};
if !output.status.success() {
return configured_binary_path_from_plist_xml(plist_path);
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !value.is_empty() {
return Some(value);
}
configured_binary_path_from_plist_xml(plist_path)
}
pub(crate) fn configured_binary_path_from_plist_xml(plist_path: &Path) -> Option<String> {
let plist = fs::read_to_string(plist_path).ok()?;
let program_args = plist.find("<key>ProgramArguments</key>")?;
let after_key = &plist[program_args..];
let array_start = after_key.find("<array")?;
let after_array_tag = after_key[array_start..].find('>')? + array_start + 1;
let array = &after_key[after_array_tag..];
let array_end = array.find("</array>")?;
let array = &array[..array_end];
let string_start = array.find("<string>")? + "<string>".len();
let string_end = array[string_start..].find("</string>")? + string_start;
let value = decode_plist_xml_text(array[string_start..string_end].trim());
(!value.is_empty()).then_some(value)
}
pub(in crate::cli) fn decode_plist_xml_text(value: &str) -> String {
value
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
pub(in crate::cli) fn process_command(pid: i64) -> Option<String> {
let output = ProcessCommand::new("ps")
.args(["-ww", "-p", &pid.to_string(), "-o", "command="])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!value.is_empty()).then_some(value)
}
pub(crate) fn command_binary_path(command: &str) -> Option<String> {
let trimmed = command.trim();
if trimmed.is_empty() {
return None;
}
if let Some(rest) = trimmed.strip_prefix('"') {
let end = rest.find('"')?;
let value = &rest[..end];
return (!value.is_empty()).then_some(value.to_string());
}
if let Some(rest) = trimmed.strip_prefix('\'') {
let end = rest.find('\'')?;
let value = &rest[..end];
return (!value.is_empty()).then_some(value.to_string());
}
trimmed.split_whitespace().next().map(ToString::to_string)
}
pub(crate) fn watcher_binary_mismatch_note<'a>(
current_binary_path: &Path,
providers: impl IntoIterator<Item = &'a ServiceProviderStatus>,
) -> Option<String> {
for provider in providers {
if !(provider.installed || provider.loaded || provider.running) {
continue;
}
let candidates: Vec<&str> = provider.running_binary_path.as_deref().map_or_else(
|| {
provider
.configured_binary_path
.iter()
.map(String::as_str)
.collect()
},
|running| vec![running],
);
for candidate in candidates {
if !paths_equivalent(current_binary_path, Path::new(candidate)) {
return Some(format!(
"{} watcher uses {}, but this command is {}. Restart the watcher with the same clipmem binary to avoid mixed-build behavior.",
provider.provider.as_str(),
candidate,
current_binary_path.display()
));
}
}
}
None
}
pub(in crate::cli) fn paths_equivalent(left: &Path, right: &Path) -> bool {
if left == right {
return true;
}
match (fs::canonicalize(left), fs::canonicalize(right)) {
(Ok(left), Ok(right)) => left == right,
_ => false,
}
}