use eyre::Result;
use serde_json::json;
use crate::cli::args::TruncateOptions;
use crate::config::Config;
use crate::path::PathExt;
use crate::system;
use crate::system::files::FileState;
use crate::ui::table::MiseTable;
#[derive(Debug, usage_rs::Args)]
#[usage(
visible_alias = "ls",
verbatim_doc_comment,
example(
r###"mise dot status
mise dot status ~/.zshrc
mise dot status --json
mise dot status --missing # exit 1 if anything is out of sync"###
)
)]
pub(crate) struct DotfilesStatus {
#[usage(flatten)]
truncate: TruncateOptions,
#[usage(value_name = "TARGET")]
targets: Vec<String>,
#[usage(long, short = 'J')]
json: bool,
#[usage(long, verbatim_doc_comment)]
missing: bool,
#[usage(long)]
prompt_secrets: bool,
}
impl DotfilesStatus {
pub(crate) async fn run(self) -> Result<()> {
let config = Config::get().await?;
let secrets = system::secrets::resolve(&config, self.prompt_secrets)?;
let mut any_missing = false;
let all_files = system::files::files_from_config(&config)?;
system::files::validate_composed_file_footprints(&all_files)?;
let files = all_files
.iter()
.filter(|req| {
system::files::matches_target(&req.target, &req.target_raw, &self.targets)
})
.cloned()
.collect::<Vec<_>>();
let history = super::history_status::report().await?;
let mut file_rows: Vec<Vec<String>> = vec![];
let mut json_files = vec![];
for req in &files {
let (state, removable) = match system::files::check(&config, req, &secrets) {
Ok(state) => (state, true),
Err(err) => (FileState::Differs(format!("{err}")), false),
};
let removal = req.mode == system::files::FileMode::Absent && removable;
let (omitted, nested) = match state {
FileState::Tracked => (
paths_under(&history.omitted, &req.target),
paths_under(&history.nested, &req.target),
),
_ => (0, 0),
};
let absent = matches!(state, FileState::Applied)
.then(|| system::files::permissions_target_absent(req))
.flatten();
let state_str = match &state {
FileState::Applied if removal => "absent".to_string(),
FileState::Applied => match absent {
Some(reason) => format!("applied ({reason})"),
None => "applied".to_string(),
},
FileState::Missing => "missing".to_string(),
FileState::SourceMissing => "source missing".to_string(),
FileState::Differs(reason) if removal => format!("would remove ({reason})"),
FileState::Differs(reason) => format!("differs ({reason})"),
FileState::Tracked if omitted > 0 || nested > 0 => {
let mut parts = vec![];
if omitted > 0 {
parts.push(format!("{omitted} omitted"));
}
if nested > 0 {
parts.push(format!("{nested} nested"));
}
format!("tracked ({})", parts.join(", "))
}
FileState::Tracked => "tracked".to_string(),
};
any_missing |= !matches!(state, FileState::Applied | FileState::Tracked);
if self.json {
let mut entry = json!({
"target": req.target_raw,
"source": req.mode.has_source()
.then(|| req.source.display_user()),
"mode": req.mode.name(),
"origin": &req.origin,
"state": match &state {
FileState::Applied => "applied",
FileState::Missing => "missing",
FileState::SourceMissing => "source_missing",
FileState::Differs(_) => "differs",
FileState::Tracked => "tracked",
},
"omitted": omitted,
"nested": nested,
});
if let Some(permissions) = req.permissions {
entry["permissions"] = json!(format!("{permissions:04o}"));
}
if let FileState::Differs(reason) = &state {
entry["reason"] = json!(if removal {
format!("{reason}; will be removed")
} else {
reason.clone()
});
} else if let Some(reason) = absent {
entry["reason"] = json!(reason);
}
json_files.push(entry);
} else {
file_rows.push(vec![
req.target_raw.clone(),
req.mode.name().to_string(),
match req.mode {
system::files::FileMode::Content => "inline".to_string(),
system::files::FileMode::Absent => "-".to_string(),
system::files::FileMode::Permissions => "-".to_string(),
_ => req.source.display_user(),
},
req.origin.config.display_user(),
state_str,
]);
}
}
let all_edits = system::edits::edits_from_config(&config)?;
let edits = all_edits
.iter()
.filter(|req| system::edits::matches_target(req, &self.targets))
.cloned()
.collect::<Vec<_>>();
if files.is_empty()
&& edits.is_empty()
&& !self.targets.is_empty()
&& (!all_files.is_empty() || !all_edits.is_empty())
{
eyre::bail!(
"no dotfiles matched target filter: {}",
self.targets.join(", ")
);
}
let mut edit_rows: Vec<Vec<String>> = vec![];
let mut json_edits = vec![];
for req in &edits {
let state = match system::edits::check(&config, req) {
Ok(state) => state,
Err(err) => FileState::Differs(format!("{err}")),
};
let state_str = match &state {
FileState::Applied => "applied".to_string(),
FileState::Missing => "missing".to_string(),
FileState::SourceMissing => "source missing".to_string(),
FileState::Differs(reason) => format!("differs ({reason})"),
FileState::Tracked => "tracked".to_string(),
};
any_missing |= !matches!(state, FileState::Applied | FileState::Tracked);
if self.json {
json_edits.push(json!({
"path": req.path_raw,
"edit": req.describe_op(),
"origin": &req.origin,
"state": match &state {
FileState::Applied => "applied",
FileState::Missing => "missing",
FileState::SourceMissing => "source_missing",
FileState::Differs(_) => "differs",
FileState::Tracked => "tracked",
},
}));
} else {
edit_rows.push(vec![
req.path_raw.clone(),
req.describe_op(),
req.origin.config.display_user(),
state_str,
]);
}
}
if files.is_empty() && edits.is_empty() {
super::warn_if_dotfiles_ignored();
}
if self.json {
miseprintln!(
"{}",
serde_json::to_string_pretty(&json!({
"files": json_files,
"edits": json_edits,
"history": history,
}))?
);
} else {
if file_rows.is_empty() && edit_rows.is_empty() {
info!("nothing configured in [dotfiles]");
}
if !file_rows.is_empty() {
let mut table =
MiseTable::new(false, &["Target", "Mode", "Source", "Config", "State"]);
table.truncate(self.truncate.truncate);
for row in file_rows {
table.add_row(row);
}
table.print()?;
}
if !edit_rows.is_empty() {
let mut table = MiseTable::new(false, &["File", "Edit", "Config", "State"]);
table.truncate(self.truncate.truncate);
for row in edit_rows {
table.add_row(row);
}
table.print()?;
}
super::history_status::print(&history)?;
}
if self.missing && any_missing {
return Err(crate::request_exit(1));
}
Ok(())
}
}
fn paths_under(
reported: &[crate::system::history::store::PathReason],
target: &std::path::Path,
) -> usize {
let target = crate::system::history::tracked::normalize_target(target);
let display = crate::file::display_path(&target);
reported
.iter()
.filter(|reported| crate::system::history::tracked::display_under(&reported.path, &display))
.count()
}