mise 2026.9.14

Dev tools, env vars, and tasks in one CLI
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;

/// Show the status of dotfiles from `[dotfiles]`
///
/// Template entries are rendered to compare their output; trusted template
/// functions may execute. JSON includes each entry's origin and uses the states
/// `applied`, `missing`, `differs`, `source_missing`, and `tracked`.
///
/// The management state of every declaration (applied, missing, differs,
/// tracked) followed by the history state: what is tracked, the latest
/// checkpoint, unfinished operations, and whether edits are saved
/// automatically.
#[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,

    /// Only show these targets
    #[usage(value_name = "TARGET")]
    targets: Vec<String>,

    /// Output in JSON format
    #[usage(long, short = 'J')]
    json: bool,

    /// Exit with code 1 if any configured dotfiles are not in their desired
    /// state (missing, source missing, differs)
    #[usage(long, verbatim_doc_comment)]
    missing: bool,

    /// Prompt securely for missing bootstrap secret inputs
    #[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<_>>();
        // the history walk decides what a tracked entry really saves, so a
        // tracked row can say how many of its files every save leaves out
        let history = super::history_status::report().await?;
        let mut file_rows: Vec<Vec<String>> = vec![];
        let mut json_files = vec![];
        for req in &files {
            // an absent entry that cannot be checked (a directory at the
            // target, say) is an error, not a pending removal
            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}"));
                }
                // e.g. an absent target that is still present, or a
                // permissions-only target that does not exist
                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,
                ]);
            }
        }

        // the hint goes to stderr, so an empty --json result explains itself
        // too without anything landing in the parsed output
        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(())
    }
}

/// How many reported paths are `target` itself or lie beneath it.
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()
}