use std::path::Path;
use anyhow::Result;
use crate::prompt::confirm_default_yes;
use crate::repo::Repo;
use crate::scan;
use crate::time::now;
use anyhow::bail;
use std::collections::{BTreeMap, HashSet};
use crate::model::{Commit, short};
const ILLEGAL: &[char] = &['"', '*', ':', '<', '>', '?', '|', '\\'];
fn bad_component(comp: &str, strict: bool) -> bool {
comp.chars().any(|c| c.is_control())
|| (strict && (comp.chars().any(|c| ILLEGAL.contains(&c)) || comp.ends_with([' ', '.'])))
}
pub fn unportable(path: &str, strict: bool) -> bool {
path.split('/').any(|c| bad_component(c, strict))
}
pub fn display(path: &str) -> String {
path.chars()
.map(|c| match c {
'\n' => "⏎".to_string(),
'\t' => "⇥".to_string(),
'\r' => "␍".to_string(),
c if c.is_control() => format!("\\u{{{:x}}}", c as u32),
c => c.to_string(),
})
.collect()
}
pub fn sanitize(path: &str, strict: bool) -> String {
path.split('/')
.map(|comp| {
let mut s: String = comp
.chars()
.filter(|c| !c.is_control())
.map(|c| {
if strict && ILLEGAL.contains(&c) {
'_'
} else {
c
}
})
.collect();
if strict {
while s.ends_with([' ', '.']) {
s.pop();
}
}
let s = s.trim().to_string();
if s.is_empty() { "_".to_string() } else { s }
})
.collect::<Vec<_>>()
.join("/")
}
pub fn probe_restrictive(root: &Path) -> bool {
let dir = root.join(".stowe");
if std::fs::create_dir_all(&dir).is_err() {
return true;
}
let probe = dir.join(".probe:*?");
match std::fs::write(&probe, b"") {
Ok(()) => {
let _ = std::fs::remove_file(&probe);
false
}
Err(_) => true,
}
}
pub(crate) fn warn_unportable(d: &scan::Diff) {
use colored::Colorize;
let mut bad: Vec<&String> = d.added.iter().filter(|p| unportable(p, true)).collect();
bad.extend(
d.moved
.iter()
.map(|(_, to)| to)
.filter(|p| unportable(p, true)),
);
if bad.is_empty() {
return;
}
eprintln!(
"\n{} {} name(s) may not be storable on external drives (FAT/exFAT/NTFS):",
"warning:".yellow().bold(),
bad.len()
);
for p in bad {
eprintln!(" {}", display(p).yellow());
}
eprintln!(
"{}",
"(a push to such a drive will offer to rename them)".dimmed()
);
}
pub(crate) fn preflight_names(repo: &Repo, name: &str, root: &Path) -> Result<()> {
use colored::Colorize;
let strict = probe_restrictive(root);
let manifest = repo.head_manifest()?;
let offenders: Vec<String> = manifest
.iter()
.map(|e| e.path.clone())
.filter(|p| unportable(p, strict))
.collect();
if offenders.is_empty() {
return Ok(());
}
eprintln!(
"{} {} committed name(s) can't be stored on `{name}`:",
"note:".yellow().bold(),
offenders.len()
);
for p in &offenders {
eprintln!(" {}", display(p).yellow());
}
if !confirm_default_yes("Rename them locally to safe names and continue?") {
bail!("push aborted - fix the names and push again");
}
let mut used: HashSet<String> = manifest.iter().map(|e| e.path.clone()).collect();
let mut map: BTreeMap<String, String> = BTreeMap::new();
for old in &offenders {
let base = sanitize(old, strict);
let mut target = base.clone();
let mut n = 1;
while used.contains(&target) {
target = bump_name(&base, n);
n += 1;
}
used.insert(target.clone());
map.insert(old.clone(), target);
}
for (old, new) in &map {
let src = repo.root.join(old);
if !src.exists() {
bail!(
"`{}` is no longer in the working tree - commit your changes, then push again",
display(old)
);
}
let dst = repo.root.join(new);
if let Some(p) = dst.parent() {
std::fs::create_dir_all(p)?;
}
std::fs::rename(&src, &dst)?;
let mut dir = src.parent();
while let Some(d) = dir {
if d == repo.root || std::fs::remove_dir(d).is_err() {
break;
}
dir = d.parent();
}
println!("renamed {} -> {new}", display(old));
}
let mut files = manifest;
for e in &mut files {
if let Some(new) = map.get(&e.path) {
e.path = new.clone();
}
}
files.sort_by(|a, b| a.path.cmp(&b.path));
let commit = Commit {
parent: repo.head()?,
message: "fix: portable file names".to_string(),
time: now(),
files,
};
let hash = repo.write_commit(&commit)?;
repo.set_head(&hash)?;
println!("committed {} \"fix: portable file names\"", short(&hash));
if let Some(mut idx) = repo.read_index()? {
for e in &mut idx {
if let Some(new) = map.get(&e.path) {
e.path = new.clone();
}
}
idx.sort_by(|a, b| a.path.cmp(&b.path));
repo.write_index(&idx)?;
}
Ok(())
}
pub(crate) fn bump_name(path: &str, n: usize) -> String {
let (dir, file) = match path.rsplit_once('/') {
Some((d, f)) => (Some(d), f),
None => (None, path),
};
let bumped = match file.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => format!("{stem} ({n}).{ext}"),
_ => format!("{file} ({n})"),
};
match dir {
Some(d) => format!("{d}/{bumped}"),
None => bumped,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_characters_are_never_portable() {
assert!(unportable("Music/\nt.A.T.u. - Remix", false));
assert!(unportable("a\tb.mp3", true));
}
#[test]
fn windows_illegal_characters_only_matter_in_strict_mode() {
assert!(unportable("Album: The Best.mp3", true));
assert!(!unportable("Album: The Best.mp3", false), "fine on ext4");
for bad in ['"', '*', ':', '<', '>', '?', '|', '\\'] {
assert!(
unportable(&format!("x{bad}.mp3"), true),
"{bad} should fail"
);
}
}
#[test]
fn trailing_dots_and_spaces_are_strict_only() {
assert!(unportable("song .mp3/x", true) || unportable("dir /x", true));
assert!(unportable("trailing.", true));
assert!(!unportable("trailing.", false));
}
#[test]
fn ordinary_names_are_left_alone() {
assert!(!unportable("Music/Artist/Song (Remix).mp3", true));
assert!(!unportable("Nas Ne Dogonyat.mp3", true));
}
#[test]
fn display_escapes_control_characters_onto_one_line() {
assert_eq!(display("a\nb"), "a⏎b");
assert!(!display("a\nb").contains('\n'));
}
#[test]
fn sanitize_produces_a_storable_name() {
assert_eq!(sanitize("Music/\nsong.mp3", true), "Music/song.mp3");
assert_eq!(sanitize("Album: Best.mp3", true), "Album_ Best.mp3");
assert_eq!(sanitize("bad:dir/ok.mp3", true), "bad_dir/ok.mp3");
for raw in ["a\nb.mp3", "x:y?z.mp3", "trailing. "] {
assert!(!unportable(&sanitize(raw, true), true), "{raw}");
}
}
#[test]
fn sanitize_never_yields_an_empty_component() {
assert_eq!(sanitize("\n\n", true), "_");
}
}