use std::path::{Path, PathBuf};
use eyre::{Result, bail};
use indexmap::IndexMap;
use serde::Deserialize;
use crate::config::Config;
use crate::file;
use crate::path::PathExt;
use crate::system::files::FileState;
use crate::ui::prompt;
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum EditTomlEntry {
Block(String),
Table(EditTomlTable),
}
#[derive(Debug, Clone, Deserialize)]
pub struct EditTomlTable {
#[serde(default)]
pub block: Option<String>,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub template: Option<String>,
#[serde(default)]
pub line: Option<String>,
#[serde(default)]
pub comment: Option<String>,
}
#[derive(Debug, Clone)]
pub enum BlockSource {
Inline(String),
File(PathBuf),
}
#[derive(Debug, Clone)]
pub enum EditOp {
Block {
source: BlockSource,
template: bool,
comment: String,
},
Line {
line: String,
},
}
#[derive(Debug, Clone)]
pub struct EditRequest {
pub path_raw: String,
pub path: PathBuf,
pub id: String,
pub op: EditOp,
pub base: PathBuf,
pub config_path: PathBuf,
}
impl EditRequest {
pub fn describe_op(&self) -> String {
match &self.op {
EditOp::Block { .. } => format!("block:{}", self.id),
EditOp::Line { .. } => format!("line:{}", self.id),
}
}
pub fn config_key(&self) -> String {
format!("{}/{}", self.path_raw.trim_end_matches('/'), self.id)
}
}
pub fn matches_target(req: &EditRequest, filters: &[String]) -> bool {
filters.is_empty()
|| filters.iter().any(|filter| {
filter == &req.path_raw
|| filter == &req.config_key()
|| filter == &format!("{}/{}", req.path.display_user(), req.id)
|| filter.rsplit_once('/').is_some_and(|(path, id)| {
id == req.id && {
let resolved = crate::system::files::resolve_target_arg(path);
resolved == req.path
}
})
|| {
let resolved = crate::system::files::resolve_target_arg(filter);
resolved == req.path
}
})
}
pub fn edits_from_config(config: &Config) -> Vec<EditRequest> {
let mut merged: IndexMap<String, EditRequest> = IndexMap::new();
for (cf_path, cf) in config.config_files.iter().rev() {
let base = cf_path.parent().unwrap_or(Path::new(".")).to_path_buf();
let Some(dotfiles) = cf.dotfiles_config() else {
continue;
};
for (path_and_id, value) in dotfiles.0 {
let Some(entry) = edit_entry_from_toml(&path_and_id, value) else {
continue;
};
match split_edit_key(&path_and_id) {
Some((path_raw, id)) => match resolve_entry(&path_raw, id, entry, &base, cf_path) {
Ok(req) => {
merged.insert(format!("{}\u{0}{}", req.path.display(), req.id), req);
}
Err(err) => warn!("[dotfiles]: {err}"),
},
None => warn!(
"[dotfiles].\"{path_and_id}\": edit entries must end with an id path segment"
),
}
}
}
merged.into_values().collect()
}
fn edit_entry_from_toml(path_and_id: &str, value: toml::Value) -> Option<EditTomlEntry> {
match &value {
toml::Value::Table(table) => {
let is_whole_file_table = table.is_empty()
|| table.contains_key("mode")
|| table.contains_key("source")
&& !table.contains_key("block")
&& !table.contains_key("line")
&& !table.contains_key("template")
&& !table.contains_key("comment");
if is_whole_file_table {
return None;
}
}
_ => return None,
}
match value.try_into() {
Ok(entry) => Some(entry),
Err(err) => {
warn!("[dotfiles].\"{path_and_id}\": invalid edit entry: {err}");
None
}
}
}
fn split_edit_key(path_and_id: &str) -> Option<(String, String)> {
let (path, id) = path_and_id.rsplit_once('/')?;
if path.is_empty() || path == "~" || path == "/" || id.is_empty() {
return None;
}
Some((path.to_string(), id.to_string()))
}
fn resolve_entry(
path_raw: &str,
id: String,
entry: EditTomlEntry,
base: &Path,
config_path: &Path,
) -> Result<EditRequest> {
let path = file::replace_path(path_raw);
if path.is_relative() {
bail!("path \"{path_raw}\" must be absolute or start with ~/, ignoring entry");
}
if id.is_empty() || !id.chars().all(|c| c.is_alphanumeric() || "_-.".contains(c)) {
bail!(
"\"{path_raw}\".{id:?}: ids may only contain letters, digits, '_', '-', and '.', ignoring entry"
);
}
let entry = match entry {
EditTomlEntry::Block(inline) => EditTomlTable {
block: Some(inline),
source: None,
template: None,
line: None,
comment: None,
},
EditTomlEntry::Table(table) => table,
};
let is_block = entry.block.is_some() || entry.source.is_some();
let op = match (&is_block, &entry.line) {
(true, Some(_)) => {
bail!(
"\"{path_raw}\".{id}: block/source and line are mutually exclusive, ignoring entry"
)
}
(false, None) => {
bail!(
"\"{path_raw}\".{id}: no recognized operation (block, source, or line), ignoring entry"
)
}
(true, None) => {
let source = match (entry.block, entry.source) {
(Some(_), Some(_)) => {
bail!(
"\"{path_raw}\".{id}: block and source are mutually exclusive, ignoring entry"
)
}
(Some(inline), None) => BlockSource::Inline(inline),
(None, Some(src)) => {
let src = file::replace_path(&src);
BlockSource::File(if src.is_relative() {
base.join(src)
} else {
src
})
}
(None, None) => unreachable!("is_block"),
};
let template = match entry.template.as_deref() {
None => false,
Some("tera") => true,
Some(other) => {
bail!(
"\"{path_raw}\".{id}: unknown template engine '{other}' (expected \"tera\"), ignoring entry"
)
}
};
let comment = entry
.comment
.unwrap_or_else(|| infer_comment(&path).to_string());
EditOp::Block {
source,
template,
comment,
}
}
(false, Some(line)) => {
if line.contains('\n') {
bail!(
"\"{path_raw}\".{id}: line may not contain a newline; use a block for multi-line content, ignoring entry"
)
}
EditOp::Line { line: line.clone() }
}
};
Ok(EditRequest {
path_raw: path_raw.to_string(),
path,
id,
op,
base: base.to_path_buf(),
config_path: config_path.to_path_buf(),
})
}
fn infer_comment(path: &Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
"lua" => "--",
"vim" | "vimrc" => "\"",
"el" | "lisp" | "scm" => ";;",
"ini" | "reg" => ";",
"c" | "h" | "cpp" | "hpp" | "cc" | "js" | "ts" | "jsx" | "tsx" | "rs" | "go" | "java"
| "kt" | "swift" | "cs" | "scala" | "php" | "zig" => "//",
_ => "#",
}
}
fn begin_marker(comment: &str, id: &str) -> String {
format!("{comment} >>> mise:{id} >>> managed by mise — do not edit between markers")
}
fn end_marker(comment: &str, id: &str) -> String {
format!("{comment} <<< mise:{id} <<<")
}
fn is_marker_line(line: &str, pat: &str, comment: &str) -> bool {
let trimmed = line.trim_start();
match trimmed.find(pat) {
Some(idx) => {
let prefix = trimmed[..idx].trim();
prefix == comment || (prefix.len() <= 8 && !prefix.chars().any(|c| c.is_alphanumeric()))
}
None => false,
}
}
fn find_block(
lines: &[&str],
id: &str,
comment: &str,
) -> std::result::Result<Option<(usize, usize)>, String> {
let begin_pat = format!(">>> mise:{id} >>>");
let end_pat = format!("<<< mise:{id} <<<");
let begins: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| is_marker_line(l, &begin_pat, comment))
.map(|(i, _)| i)
.collect();
let ends: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| is_marker_line(l, &end_pat, comment))
.map(|(i, _)| i)
.collect();
match (begins.as_slice(), ends.as_slice()) {
([], []) => Ok(None),
([b], [e]) if b < e => Ok(Some((*b, *e))),
([_], [_]) => Err("end marker appears before begin marker".into()),
([], _) => Err("end marker without begin marker".into()),
(_, []) => Err("begin marker without end marker".into()),
_ => Err("duplicate markers".into()),
}
}
fn desired_content(config: &Config, req: &EditRequest) -> Result<Option<String>> {
let EditOp::Block {
source,
template,
comment,
} = &req.op
else {
return Ok(None);
};
let id = &req.id;
let raw = match source {
BlockSource::Inline(s) => s.clone(),
BlockSource::File(p) => file::read_to_string(p)?,
};
let content = if *template {
let mut tera = crate::tera::get_tera(Some(&req.base));
tera.render_str(&raw, &config.tera_ctx).map_err(|err| {
eyre::eyre!(
"[dotfiles].\"{}/{}\": failed to render template: {err}",
req.path_raw,
req.id
)
})?
} else {
raw
};
let content = content.trim_end_matches('\n').to_string();
for pat in [format!(">>> mise:{id} >>>"), format!("<<< mise:{id} <<<")] {
if content.lines().any(|l| is_marker_line(l, &pat, comment)) {
bail!(
"[dotfiles].\"{}/{}\": block content may not contain its own marker lines",
req.path_raw,
req.id
);
}
}
Ok(Some(content))
}
pub fn check(config: &Config, req: &EditRequest) -> Result<FileState> {
if let EditOp::Block {
source: BlockSource::File(p),
..
} = &req.op
&& !p.exists()
{
return Ok(FileState::SourceMissing);
}
match precheck(req)? {
Some(EditCheck::State(state)) => Ok(state),
Some(EditCheck::Blocked(reason)) => Ok(FileState::Differs(reason)),
None => {
let desired = desired_content(config, req)?;
block_state(req, desired.as_deref())
}
}
}
const SYMLINK_REASON: &str = "target is a symlink; edit the real file instead";
enum EditCheck {
State(FileState),
Blocked(String),
}
fn precheck(req: &EditRequest) -> Result<Option<EditCheck>> {
if req.path.is_symlink() {
return Ok(Some(EditCheck::Blocked(SYMLINK_REASON.into())));
}
if !req.path.exists() {
return Ok(Some(EditCheck::State(FileState::Missing)));
}
let text = file::read_to_string(&req.path)?;
let lines: Vec<&str> = text.lines().collect();
match &req.op {
EditOp::Block { comment, .. } => match find_block(&lines, &req.id, comment) {
Err(reason) => Ok(Some(EditCheck::Blocked(reason))),
Ok(None) => Ok(Some(EditCheck::State(FileState::Missing))),
Ok(Some(_)) => Ok(None),
},
EditOp::Line { line } => Ok(Some(EditCheck::State(if lines.contains(&line.as_str()) {
FileState::Applied
} else {
FileState::Missing
}))),
}
}
fn block_state(req: &EditRequest, desired: Option<&str>) -> Result<FileState> {
let EditOp::Block { comment, .. } = &req.op else {
unreachable!("only blocks reach a content comparison");
};
let id = &req.id;
let text = file::read_to_string(&req.path)?;
let lines: Vec<&str> = text.lines().collect();
match find_block(&lines, id, comment) {
Ok(Some((begin, end))) => {
let current = lines[begin + 1..end].join("\n");
if current == desired.expect("resolved block content") {
Ok(FileState::Applied)
} else {
Ok(FileState::Differs("block content differs".into()))
}
}
_ => Ok(FileState::Differs("markers changed during check".into())),
}
}
pub struct ApplyOpts {
pub dry_run: bool,
pub verbose: bool,
pub yes: bool,
}
pub fn apply(config: &Config, requests: &[EditRequest], opts: &ApplyOpts) -> Result<()> {
let mut todo: Vec<(&EditRequest, Option<String>)> = vec![];
let mut problems = vec![];
for req in requests {
if let EditOp::Block {
source: BlockSource::File(p),
..
} = &req.op
&& !p.exists()
{
problems.push(format!(
" \"{}\" ({}): source does not exist: {}",
req.path_raw,
req.describe_op(),
p.display_user()
));
continue;
}
let pre = match precheck(req) {
Ok(pre) => pre,
Err(err) => {
problems.push(format!(
" \"{}\" ({}): {err}",
req.path_raw,
req.describe_op()
));
continue;
}
};
match &pre {
Some(EditCheck::Blocked(reason)) => {
problems.push(format!(
" \"{}\" ({}): {reason}",
req.path_raw,
req.describe_op()
));
continue;
}
Some(EditCheck::State(FileState::Applied)) => continue,
_ => {}
}
if opts.dry_run && matches!(&req.op, EditOp::Block { template: true, .. }) {
todo.push((req, None));
continue;
}
let desired = match desired_content(config, req) {
Ok(desired) => desired,
Err(err) => {
problems.push(format!(" {err}"));
continue;
}
};
match pre {
None => match block_state(req, desired.as_deref()) {
Ok(FileState::Applied) => continue,
Ok(_) => todo.push((req, desired)),
Err(err) => {
problems.push(format!(
" \"{}\" ({}): {err}",
req.path_raw,
req.describe_op()
));
continue;
}
},
Some(_) => todo.push((req, desired)),
}
}
if !problems.is_empty() {
bail!(
"edits: cannot apply these entries, fix them manually:\n{}",
problems.join("\n")
);
}
if todo.is_empty() {
info!("edits: all edits are applied");
return Ok(());
}
if opts.dry_run {
for (req, desired) in &todo {
let conditional =
desired.is_none() && matches!(&req.op, EditOp::Block { template: true, .. });
let suffix = if conditional { " (if changed)" } else { "" };
miseprintln!(
"edit {} ({}){suffix}",
req.path.display_user(),
req.describe_op()
);
if opts.verbose && !conditional {
miseprintln!(" desired {}", req.describe_op());
}
}
return Ok(());
}
if !opts.yes && console::user_attended_stderr() {
let list = todo
.iter()
.map(|(r, _)| format!("{} ({})", r.path_raw, r.describe_op()))
.collect::<Vec<_>>()
.join(", ");
if !prompt::confirm(format!("edits: apply {list}?"))? {
info!("edits: skipped");
return Ok(());
}
}
for (req, desired) in &todo {
apply_one(req, desired.as_deref())?;
}
info!(
"edits: applied {}",
todo.iter()
.map(|(r, _)| format!("{} ({})", r.path_raw, r.describe_op()))
.collect::<Vec<_>>()
.join(", ")
);
Ok(())
}
fn apply_one(req: &EditRequest, desired: Option<&str>) -> Result<()> {
debug!("edits: {} ({})", req.path.display_user(), req.describe_op());
if let Some(parent) = req.path.parent() {
file::create_dir_all(parent)?;
}
let text = if req.path.exists() {
file::read_to_string(&req.path)?
} else {
String::new()
};
let mut lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
match &req.op {
EditOp::Block { comment, .. } => {
let id = &req.id;
let desired = desired.expect("resolved block content");
let mut block = vec![begin_marker(comment, id)];
if !desired.is_empty() {
block.extend(desired.lines().map(|l| l.to_string()));
}
block.push(end_marker(comment, id));
match find_block(
&lines.iter().map(|l| l.as_str()).collect::<Vec<_>>(),
id,
comment,
) {
Ok(Some((begin, end))) => {
lines.splice(begin..=end, block);
}
Ok(None) => lines.extend(block),
Err(reason) => bail!(
"edits: \"{}\": {reason}, fix the file manually",
req.path_raw
),
}
}
EditOp::Line { line } => {
if !lines.iter().any(|l| l == line) {
lines.push(line.clone());
}
}
}
let mut out = lines.join("\n");
out.push('\n');
file::write(&req.path, &out)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_infer_comment() {
assert_eq!(infer_comment(Path::new("/a/.zshrc")), "#");
assert_eq!(infer_comment(Path::new("/etc/hosts")), "#");
assert_eq!(infer_comment(Path::new("/a/init.lua")), "--");
assert_eq!(infer_comment(Path::new("/a/foo.rs")), "//");
assert_eq!(infer_comment(Path::new("/a/foo.ini")), ";");
}
#[test]
fn test_find_block() {
let lines = vec![
"before",
"# >>> mise:a >>> managed by mise",
"content",
"# <<< mise:a <<<",
"after",
];
assert_eq!(find_block(&lines, "a", "#"), Ok(Some((1, 3))));
assert_eq!(find_block(&lines, "b", "#"), Ok(None));
let lines = vec!["# >>> mise:ab >>>", "# <<< mise:ab <<<"];
assert_eq!(find_block(&lines, "a", "#"), Ok(None));
let lines = vec![
"# >>> mise:a >>>",
r#"echo "keep the >>> mise:a >>> line intact""#,
"# <<< mise:a <<<",
];
assert_eq!(find_block(&lines, "a", "#"), Ok(Some((0, 2))));
let lines = vec![" # >>> mise:a >>>", " # <<< mise:a <<<"];
assert_eq!(find_block(&lines, "a", "#"), Ok(Some((0, 1))));
let lines = vec!["<!-- >>> mise:a >>>", "<!-- <<< mise:a <<<"];
assert_eq!(find_block(&lines, "a", "#"), Ok(Some((0, 1))));
let lines = vec!["# >>> mise:a >>>"];
assert!(find_block(&lines, "a", "#").is_err());
let lines = vec!["# <<< mise:a <<<", "# >>> mise:a >>>"];
assert!(find_block(&lines, "a", "#").is_err());
let lines = vec!["REM >>> mise:a >>>", "REM <<< mise:a <<<"];
assert_eq!(find_block(&lines, "a", "REM"), Ok(Some((0, 1))));
assert_eq!(find_block(&lines, "a", "#"), Ok(None));
}
}