use anyhow::{Context, Result};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
pub struct FileInfo {
pub is_dir: bool,
pub is_symlink: bool,
pub size: u64,
pub mode: u32,
pub modified_epoch: Option<u64>,
pub symlink_target: Option<PathBuf>,
pub child_count: Option<usize>,
}
pub fn file_info(path: &Path) -> Result<FileInfo> {
let meta =
std::fs::symlink_metadata(path).with_context(|| format!("情報取得: {}", path.display()))?;
let is_symlink = meta.file_type().is_symlink();
let is_dir = meta.is_dir();
let modified_epoch = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
let child_count = if is_dir {
std::fs::read_dir(path).ok().map(|rd| rd.count())
} else {
None
};
Ok(FileInfo {
is_dir,
is_symlink,
size: meta.len(),
mode: meta.permissions().mode(),
modified_epoch,
symlink_target: if is_symlink {
std::fs::read_link(path).ok()
} else {
None
},
child_count,
})
}
pub fn human_size(bytes: u64) -> String {
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
if bytes < 1024 {
return format!("{bytes} B");
}
let mut v = bytes as f64;
let mut i = 0;
while v >= 1024.0 && i < UNITS.len() - 1 {
v /= 1024.0;
i += 1;
}
format!("{v:.1} {}", UNITS[i])
}
pub fn permission_rwx(mode: u32) -> String {
let bits = mode & 0o777;
let g = |b: u32| {
format!(
"{}{}{}",
if b & 0b100 != 0 { "r" } else { "-" },
if b & 0b010 != 0 { "w" } else { "-" },
if b & 0b001 != 0 { "x" } else { "-" },
)
};
format!(
"{}{}{}",
g((bits >> 6) & 7),
g((bits >> 3) & 7),
g(bits & 7)
)
}
pub fn permission_string(mode: u32) -> String {
let bits = mode & 0o777;
let rwx = |b: u32| {
format!(
"{}{}{}",
if b & 0b100 != 0 { "r" } else { "-" },
if b & 0b010 != 0 { "w" } else { "-" },
if b & 0b001 != 0 { "x" } else { "-" },
)
};
format!(
"{}{}{} ({:03o})",
rwx((bits >> 6) & 7),
rwx((bits >> 3) & 7),
rwx(bits & 7),
bits
)
}
pub fn format_epoch_utc(secs: u64) -> String {
let days = (secs / 86400) as i64;
let rem = secs % 86400;
let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let z = days + 719468;
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
let doe = z - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let mut y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; if m <= 2 {
y += 1;
}
format!("{y:04}-{m:02}-{d:02} {h:02}:{mi:02}:{s:02} UTC")
}
pub fn format_epoch_short(secs: u64) -> String {
format_epoch_utc(secs)[..16].to_string()
}
pub struct RowMeta {
pub is_dir: bool,
pub is_symlink: bool,
pub size: u64,
pub mtime: Option<u64>,
pub mode: u32,
}
pub fn quick_meta(path: &Path) -> Option<RowMeta> {
let m = std::fs::symlink_metadata(path).ok()?;
let mtime = m
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
Some(RowMeta {
is_dir: m.is_dir(),
is_symlink: m.file_type().is_symlink(),
size: m.len(),
mtime,
mode: m.permissions().mode(),
})
}
pub fn detail_column_width(id: &str) -> Option<usize> {
match id {
"size" => Some(9),
"modified" | "mtime" => Some(16),
"perm" | "permissions" => Some(9),
"type" => Some(4),
"items" => Some(6),
_ => None,
}
}
pub fn detail_cell(id: &str, path: &Path, m: &RowMeta) -> Option<String> {
Some(match id {
"size" => {
if m.is_dir {
"--".to_string()
} else {
human_size(m.size)
}
}
"modified" | "mtime" => m.mtime.map(format_epoch_short).unwrap_or_default(),
"perm" | "permissions" => permission_rwx(m.mode),
"type" => if m.is_symlink {
"link"
} else if m.is_dir {
"dir"
} else {
"file"
}
.to_string(),
"items" => {
if m.is_dir {
std::fs::read_dir(path)
.map(|rd| rd.count().to_string())
.unwrap_or_default()
} else {
String::new()
}
}
_ => return None,
})
}
pub fn move_to_trash(paths: &[PathBuf]) -> Result<()> {
trash::delete_all(paths).context("ゴミ箱への移動に失敗しました")?;
Ok(())
}
pub fn delete_permanently(paths: &[PathBuf]) -> Result<()> {
for p in paths {
let meta =
std::fs::symlink_metadata(p).with_context(|| format!("情報取得: {}", p.display()))?;
if meta.is_dir() {
std::fs::remove_dir_all(p)
.with_context(|| format!("ディレクトリ完全削除: {}", p.display()))?;
} else {
std::fs::remove_file(p).with_context(|| format!("完全削除: {}", p.display()))?;
}
}
Ok(())
}
pub fn create_file(dir: &Path, name: &str) -> Result<PathBuf> {
let path = dir.join(name);
if path.exists() {
anyhow::bail!("既に存在します: {}", path.display());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("親ディレクトリ作成: {}", parent.display()))?;
}
std::fs::File::create(&path).with_context(|| format!("ファイル作成: {}", path.display()))?;
Ok(path)
}
pub fn create_dir(dir: &Path, name: &str) -> Result<PathBuf> {
let path = dir.join(name);
if path.exists() {
anyhow::bail!("既に存在します: {}", path.display());
}
std::fs::create_dir_all(&path)
.with_context(|| format!("ディレクトリ作成: {}", path.display()))?;
Ok(path)
}
pub fn rename(src: &Path, new_name: &str) -> Result<PathBuf> {
let parent = src.parent().unwrap_or_else(|| Path::new("."));
let dst = parent.join(new_name);
if dst == src {
return Ok(dst);
}
if dst.exists() {
anyhow::bail!("既に存在します: {}", dst.display());
}
std::fs::rename(src, &dst).with_context(|| format!("リネーム: {}", dst.display()))?;
Ok(dst)
}
pub fn render_rename_template(template: &str, n: usize, stem: &str, ext: &str) -> String {
let mut out = String::new();
let bytes = template.as_bytes();
let mut i = 0;
while i < template.len() {
if bytes[i] == b'{' {
if let Some(rel) = template[i + 1..].find('}') {
let token = &template[i + 1..i + 1 + rel];
match render_token(token, n, stem, ext) {
Some(s) => out.push_str(&s),
None => {
out.push('{');
out.push_str(token);
out.push('}');
}
}
i = i + 1 + rel + 1;
continue;
}
}
let ch = template[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
out
}
fn render_token(token: &str, n: usize, stem: &str, ext: &str) -> Option<String> {
match token {
"n" => Some(n.to_string()),
"name" => Some(stem.to_string()),
"ext" => Some(ext.to_string()),
_ => token
.strip_prefix("n:0")
.and_then(|w| w.parse::<usize>().ok())
.map(|w| format!("{n:0w$}")),
}
}
pub fn batch_rename(plan: &[(PathBuf, PathBuf)]) -> Result<()> {
let mut staged: Vec<(PathBuf, PathBuf, PathBuf)> = Vec::new();
let mut committed = 0usize;
for (i, (src, dst)) in plan.iter().enumerate() {
if src == dst {
continue;
}
let parent = src.parent().unwrap_or_else(|| Path::new("."));
let tmp = parent.join(format!(".konoma-rename-tmp-{i}"));
if tmp.exists() {
let err = anyhow::anyhow!("一時ファイル名が既存: {}", tmp.display());
return Err(rollback_batch_rename(err, &staged, committed));
}
if let Err(e) = std::fs::rename(src, &tmp) {
let err =
anyhow::Error::new(e).context(format!("一括リネーム(一時退避): {}", src.display()));
return Err(rollback_batch_rename(err, &staged, committed));
}
staged.push((tmp, src.clone(), dst.clone()));
}
for idx in 0..staged.len() {
let (tmp, _src, dst) = &staged[idx];
if dst.exists() {
let err = anyhow::anyhow!("リネーム先が既存: {}", dst.display());
return Err(rollback_batch_rename(err, &staged, committed));
}
if let Err(e) = std::fs::rename(tmp, dst) {
let err =
anyhow::Error::new(e).context(format!("一括リネーム(確定): {}", dst.display()));
return Err(rollback_batch_rename(err, &staged, committed));
}
committed += 1;
}
Ok(())
}
fn rollback_batch_rename(
mut err: anyhow::Error,
staged: &[(PathBuf, PathBuf, PathBuf)],
committed: usize,
) -> anyhow::Error {
for (tmp, _src, dst) in staged[..committed].iter().rev() {
if let Err(e) = std::fs::rename(dst, tmp) {
err = err.context(format!(
"巻き戻し失敗(確定分退避 {} → {}): {e}",
dst.display(),
tmp.display()
));
}
}
for (tmp, src, _dst) in staged.iter().rev() {
if let Err(e) = std::fs::rename(tmp, src) {
err = err.context(format!(
"巻き戻し失敗(復元 {} → {}): {e}",
tmp.display(),
src.display()
));
}
}
err
}
fn unique_name(dir: &Path, name: &str) -> String {
if !dir.join(name).exists() {
return name.to_string();
}
let p = Path::new(name);
let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or(name);
let ext = p.extension().and_then(|s| s.to_str());
let make = |suffix: &str| match ext {
Some(e) => format!("{stem}{suffix}.{e}"),
None => format!("{stem}{suffix}"),
};
let mut cand = make(" copy");
let mut i = 2;
while dir.join(&cand).exists() {
cand = make(&format!(" copy {i}"));
i += 1;
}
cand
}
fn copy_symlink(src: &Path, dst: &Path) -> Result<()> {
let target =
std::fs::read_link(src).with_context(|| format!("リンク読込: {}", src.display()))?;
std::os::unix::fs::symlink(&target, dst)
.with_context(|| format!("リンク作成: {}", dst.display()))?;
Ok(())
}
fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
if dst.starts_with(src) {
anyhow::bail!(
"コピー先がコピー元の配下です: {} ⊂ {}",
dst.display(),
src.display()
);
}
std::fs::create_dir_all(dst).with_context(|| format!("作成: {}", dst.display()))?;
for entry in std::fs::read_dir(src).with_context(|| format!("読込: {}", src.display()))? {
let entry = entry?;
let from = entry.path();
let to = dst.join(entry.file_name());
let ft = entry.file_type()?;
if ft.is_symlink() {
copy_symlink(&from, &to)?;
} else if ft.is_dir() {
copy_dir_all(&from, &to)?;
} else {
std::fs::copy(&from, &to).with_context(|| format!("コピー: {}", from.display()))?;
}
}
Ok(())
}
pub fn copy_into(dir: &Path, src: &Path) -> Result<PathBuf> {
let name = src
.file_name()
.and_then(|n| n.to_str())
.context("名前の取得に失敗")?;
let dst = dir.join(unique_name(dir, name));
let meta = std::fs::symlink_metadata(src)?;
if meta.file_type().is_symlink() {
copy_symlink(src, &dst)?;
} else if meta.is_dir() {
copy_dir_all(src, &dst)?;
} else {
std::fs::copy(src, &dst).with_context(|| format!("コピー: {}", src.display()))?;
}
Ok(dst)
}
pub fn move_into(dir: &Path, src: &Path) -> Result<PathBuf> {
let name = src
.file_name()
.and_then(|n| n.to_str())
.context("名前の取得に失敗")?;
let same = dir.join(name);
if same == src {
return Ok(same); }
let dst = dir.join(unique_name(dir, name));
if std::fs::rename(src, &dst).is_ok() {
return Ok(dst);
}
let meta = std::fs::symlink_metadata(src)?;
if meta.file_type().is_symlink() {
copy_symlink(src, &dst)?;
std::fs::remove_file(src)?;
} else if meta.is_dir() {
copy_dir_all(src, &dst)?;
std::fs::remove_dir_all(src)?;
} else {
std::fs::copy(src, &dst).with_context(|| format!("移動(コピー): {}", src.display()))?;
std::fs::remove_file(src)?;
}
Ok(dst)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn copy_into_self_subtree_is_rejected_not_infinite() {
let root = std::env::temp_dir().join("konoma_selfcopy_test");
let _ = std::fs::remove_dir_all(&root);
let parent = root.join("parent");
let child = parent.join("child");
std::fs::create_dir_all(&child).unwrap();
std::fs::write(parent.join("f.txt"), b"x").unwrap();
let err = copy_into(&child, &parent).unwrap_err();
assert!(
err.to_string().contains("配下"),
"自己包含エラーであるべき: {err}"
);
assert!(
!child.join("parent").join("parent").exists(),
"増殖したネストが残ってはならない"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn create_and_rename_respect_existing() {
let dir = std::env::temp_dir().join("konoma_fileops_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = create_file(&dir, "a.txt").unwrap();
assert!(f.is_file());
assert!(
create_file(&dir, "a.txt").is_err(),
"既存ファイルは上書きしない"
);
let nested = create_file(&dir, "sub/b.txt").unwrap();
assert!(nested.is_file());
let d = create_dir(&dir, "newdir").unwrap();
assert!(d.is_dir());
assert!(
create_dir(&dir, "newdir").is_err(),
"既存ディレクトリはエラー"
);
let renamed = rename(&f, "a2.txt").unwrap();
assert!(renamed.is_file() && !f.exists());
assert_eq!(renamed.file_name().unwrap(), "a2.txt");
assert!(rename(&renamed, "a2.txt").is_ok());
create_file(&dir, "c.txt").unwrap();
assert!(rename(&renamed, "c.txt").is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn delete_permanently_removes_file_and_dir() {
let dir = std::env::temp_dir().join("konoma_perm_delete_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("f.txt");
std::fs::write(&f, b"x").unwrap();
let sub = dir.join("sub");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("inner.txt"), b"y").unwrap();
delete_permanently(&[f.clone(), sub.clone()]).unwrap();
assert!(!f.exists(), "ファイルが完全削除される");
assert!(!sub.exists(), "ディレクトリが中身ごと完全削除される");
assert!(delete_permanently(std::slice::from_ref(&f)).is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn render_rename_template_expands_placeholders() {
assert_eq!(
render_rename_template("{name}_{n}", 1, "foo", "jpg"),
"foo_1"
);
assert_eq!(
render_rename_template("{name}_{n:03}", 5, "foo", "jpg"),
"foo_005"
);
assert_eq!(render_rename_template("img{n}", 12, "x", "png"), "img12");
assert_eq!(
render_rename_template("base.{ext}", 1, "a", "txt"),
"base.txt"
);
assert_eq!(
render_rename_template("a{bogus}b", 1, "x", "y"),
"a{bogus}b"
);
assert_eq!(render_rename_template("写真_{n}", 2, "x", "y"), "写真_2");
}
#[test]
fn batch_rename_handles_swaps_via_two_phase() {
let dir = std::env::temp_dir().join("konoma_batch_rename_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let a = dir.join("a.txt");
let b = dir.join("b.txt");
std::fs::write(&a, b"AAA").unwrap();
std::fs::write(&b, b"BBB").unwrap();
let plan = vec![(a.clone(), dir.join("c.txt")), (b.clone(), a.clone())];
batch_rename(&plan).unwrap();
assert!(!b.exists(), "b は無くなる");
assert_eq!(std::fs::read(dir.join("c.txt")).unwrap(), b"AAA", "c=元 a");
assert_eq!(std::fs::read(&a).unwrap(), b"BBB", "a=元 b");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn batch_rename_rolls_back_on_failure() {
let dir = std::env::temp_dir().join("konoma_batch_rename_rollback_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let a = dir.join("a.txt");
let b = dir.join("b.txt");
std::fs::write(&a, b"AAA").unwrap();
std::fs::write(&b, b"BBB").unwrap();
let c = dir.join("c.txt");
let d = dir.join("d.txt");
std::fs::write(&d, b"DDD").unwrap(); let plan = vec![(a.clone(), c.clone()), (b.clone(), d.clone())];
let res = batch_rename(&plan);
assert!(res.is_err(), "リネーム先衝突で失敗すること");
assert_eq!(std::fs::read(&a).unwrap(), b"AAA", "a は原状復帰");
assert_eq!(std::fs::read(&b).unwrap(), b"BBB", "b は原状復帰");
assert!(!c.exists(), "c は巻き戻しで消える");
assert_eq!(std::fs::read(&d).unwrap(), b"DDD", "既存 d は無傷");
let orphans: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with(".konoma-rename-tmp-"))
.collect();
assert!(
orphans.is_empty(),
"孤立した一時ファイルが残らない: {orphans:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn batch_rename_rolls_back_swap_on_failure() {
let dir = std::env::temp_dir().join("konoma_batch_rename_swap_rollback_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let one = dir.join("1.txt");
let two = dir.join("2.txt");
let c = dir.join("c.txt");
let d = dir.join("d.txt");
std::fs::write(&one, b"ONE").unwrap();
std::fs::write(&two, b"TWO").unwrap();
std::fs::write(&c, b"CCC").unwrap();
std::fs::write(&d, b"DDD").unwrap();
let plan = vec![
(two.clone(), one.clone()),
(one.clone(), two.clone()),
(c.clone(), d.clone()),
];
let res = batch_rename(&plan);
assert!(res.is_err(), "リネーム先衝突で失敗すること");
assert_eq!(std::fs::read(&one).unwrap(), b"ONE", "1.txt は原状復帰");
assert_eq!(std::fs::read(&two).unwrap(), b"TWO", "2.txt は原状復帰");
assert_eq!(std::fs::read(&c).unwrap(), b"CCC", "c.txt は原状復帰");
assert_eq!(std::fs::read(&d).unwrap(), b"DDD", "既存 d は無傷");
let orphans: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with(".konoma-rename-tmp-"))
.collect();
assert!(
orphans.is_empty(),
"孤立した一時ファイルが残らない: {orphans:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn copy_into_duplicates_and_recurses() {
let dir = std::env::temp_dir().join("konoma_copy_into_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"AAA").unwrap();
let sub = dir.join("sub");
std::fs::create_dir_all(&sub).unwrap();
let d = copy_into(&dir, &dir.join("a.txt")).unwrap();
assert_eq!(d.file_name().unwrap(), "a copy.txt");
assert_eq!(std::fs::read(&d).unwrap(), b"AAA");
let d2 = copy_into(&dir, &dir.join("a.txt")).unwrap();
assert_eq!(d2.file_name().unwrap(), "a copy 2.txt");
let d3 = copy_into(&sub, &dir.join("a.txt")).unwrap();
assert_eq!(d3, sub.join("a.txt"));
let tree = dir.join("tree");
std::fs::create_dir_all(tree.join("inner")).unwrap();
std::fs::write(tree.join("inner").join("x.txt"), b"x").unwrap();
copy_into(&sub, &tree).unwrap();
assert!(sub.join("tree").join("inner").join("x.txt").is_file());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn move_into_moves_and_noop_same_dir() {
let dir = std::env::temp_dir().join("konoma_move_into_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let sub = dir.join("sub");
std::fs::create_dir_all(&sub).unwrap();
let d = move_into(&sub, &dir.join("a.txt")).unwrap();
assert!(d.is_file() && !dir.join("a.txt").exists());
assert_eq!(d, sub.join("a.txt"));
let back = move_into(&sub, &sub.join("a.txt")).unwrap();
assert_eq!(back, sub.join("a.txt"));
assert!(sub.join("a.txt").is_file());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn copy_and_move_preserve_symlinks() {
let dir = std::env::temp_dir().join("konoma_symlink_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("target.txt"), b"REAL").unwrap();
let real_dir = dir.join("realdir");
std::fs::create_dir_all(&real_dir).unwrap();
std::fs::write(real_dir.join("inner.txt"), b"in").unwrap();
let link_f = dir.join("link_f");
let link_d = dir.join("link_d");
std::os::unix::fs::symlink(dir.join("target.txt"), &link_f).unwrap();
std::os::unix::fs::symlink(&real_dir, &link_d).unwrap();
let into = dir.join("into");
std::fs::create_dir_all(&into).unwrap();
let cf = copy_into(&into, &link_f).unwrap();
assert!(
std::fs::symlink_metadata(&cf)
.unwrap()
.file_type()
.is_symlink(),
"symlink→file はコピー後も symlink"
);
assert_eq!(std::fs::read_link(&cf).unwrap(), dir.join("target.txt"));
let cd = copy_into(&into, &link_d).unwrap();
assert!(
std::fs::symlink_metadata(&cd)
.unwrap()
.file_type()
.is_symlink(),
"symlink→dir はコピー後も symlink"
);
assert_eq!(std::fs::read_link(&cd).unwrap(), real_dir);
let parent = dir.join("parent");
std::fs::create_dir_all(&parent).unwrap();
std::os::unix::fs::symlink(&real_dir, parent.join("nested_link")).unwrap();
let cp = copy_into(&into, &parent).unwrap();
assert!(
std::fs::symlink_metadata(cp.join("nested_link"))
.unwrap()
.file_type()
.is_symlink(),
"再帰コピー内の symlink も保たれる"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn file_info_and_formatters() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(512), "512 B");
assert_eq!(human_size(1024), "1.0 KB");
assert_eq!(human_size(1536), "1.5 KB");
assert_eq!(human_size(1048576), "1.0 MB");
assert_eq!(permission_string(0o755), "rwxr-xr-x (755)");
assert_eq!(permission_string(0o100644), "rw-r--r-- (644)"); assert_eq!(format_epoch_utc(0), "1970-01-01 00:00:00 UTC");
assert_eq!(format_epoch_utc(1577836800), "2020-01-01 00:00:00 UTC");
assert_eq!(
format_epoch_utc(1718323200 + 45296),
"2024-06-14 12:34:56 UTC"
);
assert_eq!(format_epoch_short(1718323200 + 45296), "2024-06-14 12:34");
assert_eq!(permission_rwx(0o100755), "rwxr-xr-x");
assert_eq!(detail_column_width("size"), Some(9));
assert_eq!(detail_column_width("type"), Some(4));
assert_eq!(detail_column_width("items"), Some(6));
assert_eq!(detail_column_width("bogus"), None);
let dir = std::env::temp_dir().join("konoma_fileinfo_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"hello").unwrap();
let fi = file_info(&dir.join("a.txt")).unwrap();
assert!(!fi.is_dir && fi.size == 5 && fi.child_count.is_none());
let di = file_info(&dir).unwrap();
assert!(di.is_dir && di.child_count == Some(1));
let fm = quick_meta(&dir.join("a.txt")).unwrap();
assert_eq!(
detail_cell("size", &dir.join("a.txt"), &fm).as_deref(),
Some("5 B")
);
assert_eq!(
detail_cell("type", &dir.join("a.txt"), &fm).as_deref(),
Some("file")
);
let dm = quick_meta(&dir).unwrap();
assert_eq!(detail_cell("size", &dir, &dm).as_deref(), Some("--"));
assert_eq!(detail_cell("type", &dir, &dm).as_deref(), Some("dir"));
assert_eq!(detail_cell("items", &dir, &dm).as_deref(), Some("1")); assert_eq!(detail_cell("bogus", &dir, &dm), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
#[ignore] fn trash_moves_file_out_of_place() {
let dir = std::env::temp_dir().join("konoma_trash_test");
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("trashme.txt");
std::fs::write(&f, b"x").unwrap();
move_to_trash(std::slice::from_ref(&f)).unwrap();
assert!(!f.exists(), "ゴミ箱へ送られて元の場所から消える");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn move_to_trash_removes_from_original_then_cleanup() {
let dir = std::env::temp_dir().join("konoma_trash_live_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let name = "konoma_trash_probe_5f3a9.txt"; let f = dir.join(name);
std::fs::write(&f, b"trash me").unwrap();
move_to_trash(std::slice::from_ref(&f)).unwrap();
assert!(!f.exists(), "ゴミ箱送りで元の場所から消える");
if let Some(home) = std::env::var_os("HOME") {
let _ = std::fs::remove_file(PathBuf::from(home).join(".Trash").join(name));
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn create_file_errors_when_parent_is_a_file() {
let dir = std::env::temp_dir().join("konoma_create_file_parent_err_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("blocker"), b"x").unwrap();
let err = create_file(&dir, "blocker/inner.txt").unwrap_err();
assert!(
err.to_string().contains("親ディレクトリ作成"),
"親作成エラー: {err}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn copy_and_move_into_error_on_missing_source() {
let dir = std::env::temp_dir().join("konoma_copy_move_missing_src_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let dst = dir.join("dst");
std::fs::create_dir_all(&dst).unwrap();
let missing = dir.join("ghost.txt");
assert!(
copy_into(&dst, &missing).is_err(),
"存在しない src のコピーは Err"
);
assert!(
move_into(&dst, &missing).is_err(),
"存在しない src の移動は Err"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn human_size_scales_through_large_units() {
assert_eq!(human_size(1024u64.pow(3)), "1.0 GB");
assert_eq!(human_size(1024u64.pow(4)), "1.0 TB");
assert_eq!(human_size(1024u64.pow(5)), "1.0 PB");
assert_eq!(human_size(2 * 1024u64.pow(5)), "2.0 PB");
}
}