use std::fs;
use std::io;
use std::path::Path;
use std::time::SystemTime;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ModeChange {
Absolute(u32),
Symbolic(Vec<Clause>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Clause {
who: u32,
actions: Vec<(char, String)>,
}
impl ModeChange {
pub fn parse(text: &str) -> Result<Self, String> {
let text = text.trim();
if !text.is_empty() && text.chars().all(|c| c.is_digit(8)) {
return match u32::from_str_radix(text, 8) {
Ok(mode) if mode <= 0o7777 => Ok(ModeChange::Absolute(mode)),
_ => Err(t!("Not a mode: {text}", text)),
};
}
let not_a_mode = || t!("Not a mode: {text} - 755, or u+x,go-w", text);
let mut clauses = Vec::new();
for part in text.split(',') {
let mut chars = part.trim().chars().peekable();
let mut who = 0;
while let Some(&c) = chars.peek() {
who |= match c {
'u' => 0o4700,
'g' => 0o2070,
'o' => 0o1007,
'a' => 0o7777,
_ => break,
};
chars.next();
}
if who == 0 {
who = 0o7777;
}
let mut actions = Vec::new();
while let Some(op) = chars.next() {
if !matches!(op, '+' | '-' | '=') {
return Err(not_a_mode());
}
let mut letters = String::new();
while let Some(&c) = chars.peek() {
if !matches!(c, 'r' | 'w' | 'x' | 'X' | 's' | 't') {
break;
}
letters.push(c);
chars.next();
}
actions.push((op, letters));
}
if actions.is_empty() {
return Err(not_a_mode());
}
clauses.push(Clause { who, actions });
}
Ok(ModeChange::Symbolic(clauses))
}
pub fn apply(&self, mode: u32, is_dir: bool) -> u32 {
let clauses = match self {
ModeChange::Absolute(mode) => return *mode,
ModeChange::Symbolic(clauses) => clauses,
};
let mut mode = mode & 0o7777;
for clause in clauses {
for (op, letters) in &clause.actions {
let mut bits = 0;
for letter in letters.chars() {
bits |= match letter {
'r' => 0o444,
'w' => 0o222,
'x' => 0o111,
'X' if is_dir || mode & 0o111 != 0 => 0o111,
's' => 0o6000,
't' => 0o1000,
_ => 0,
};
}
let bits = bits & clause.who;
match op {
'+' => mode |= bits,
'-' => mode &= !bits,
_ => mode = (mode & !(clause.who & 0o6777)) | bits,
}
}
}
mode
}
}
pub fn parse_time(text: &str) -> Result<SystemTime, String> {
use chrono::{Local, NaiveDateTime, TimeZone};
let text = text.trim();
let naive = NaiveDateTime::parse_from_str(text, "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(text, "%Y-%m-%d %H:%M"))
.map_err(|_| t!("Not a time: {text} - 2026-10-23 14:02:11", text))?;
Local.from_local_datetime(&naive).earliest().map(SystemTime::from).ok_or_else(|| t!("Not a time here: {text}", text))
}
pub fn format_time(time: SystemTime) -> String {
let local: chrono::DateTime<chrono::Local> = time.into();
local.format("%Y-%m-%d %H:%M:%S").to_string()
}
#[derive(Default)]
pub struct Change {
pub mode: Option<ModeChange>,
pub owner: Option<u32>,
pub group: Option<u32>,
pub modified: Option<SystemTime>,
}
impl Change {
pub fn is_empty(&self) -> bool {
self.mode.is_none() && self.owner.is_none() && self.group.is_none() && self.modified.is_none()
}
pub fn apply(&self, path: &Path, is_dir: bool) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if self.owner.is_some() || self.group.is_some() {
std::os::unix::fs::chown(path, self.owner, self.group)?;
}
if let Some(change) = &self.mode {
let mode = fs::metadata(path)?.permissions().mode();
fs::set_permissions(path, fs::Permissions::from_mode(change.apply(mode, is_dir)))?;
}
}
#[cfg(not(unix))]
let _ = is_dir;
if let Some(modified) = self.modified {
fs::File::open(path)?.set_modified(modified)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn apply(text: &str, mode: u32, is_dir: bool) -> u32 {
ModeChange::parse(text).unwrap().apply(mode, is_dir)
}
#[test]
fn octal_modes() {
assert_eq!(apply("755", 0o600, false), 0o755);
assert_eq!(apply("0644", 0o777, false), 0o644);
assert_eq!(apply("4755", 0o644, false), 0o4755);
assert!(ModeChange::parse("17777").is_err());
assert!(ModeChange::parse("789").is_err());
}
#[test]
fn symbolic_modes_as_chmod_reads_them() {
assert_eq!(apply("u+x", 0o644, false), 0o744);
assert_eq!(apply("go-w", 0o666, false), 0o644);
assert_eq!(apply("+x", 0o644, false), 0o755);
assert_eq!(apply("a=r", 0o4755, false), 0o444);
assert_eq!(apply("u=rw,go=r", 0o777, false), 0o644);
assert_eq!(apply("o-rwx,g+w", 0o755, false), 0o770);
assert_eq!(apply("u+s", 0o755, false), 0o4755);
assert_eq!(apply("+t", 0o777, true), 0o1777);
assert_eq!(apply("u-x+w", 0o544, false), 0o644);
}
#[test]
fn capital_x_is_for_directories_and_files_already_executable() {
assert_eq!(apply("a+X", 0o644, true), 0o755);
assert_eq!(apply("a+X", 0o644, false), 0o644);
assert_eq!(apply("a+X", 0o744, false), 0o755);
}
#[test]
fn what_is_not_a_mode() {
for text in ["", "u", "u+q", "x+r", "755x", "u+x,"] {
assert!(ModeChange::parse(text).is_err(), "{text:?}");
}
}
#[test]
fn times_read_back_as_they_were_written() {
let time = parse_time("2026-10-23 14:02:11").unwrap();
assert_eq!(format_time(time), "2026-10-23 14:02:11");
assert_eq!(format_time(parse_time(" 2026-01-02 03:04 ").unwrap()), "2026-01-02 03:04:00");
assert!(parse_time("23/10/26").is_err());
}
#[cfg(unix)]
#[test]
fn a_change_reaches_the_file() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("fm84-attributes-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let file = dir.join("a.txt");
fs::write(&file, "").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
let then = parse_time("2001-02-03 04:05:06").unwrap();
let change = Change {
mode: Some(ModeChange::parse("u+x,o-r").unwrap()),
modified: Some(then),
..Change::default()
};
change.apply(&file, false).unwrap();
let metadata = fs::metadata(&file).unwrap();
assert_eq!(metadata.permissions().mode() & 0o7777, 0o740);
assert_eq!(metadata.modified().unwrap(), then);
fs::set_permissions(&file, fs::Permissions::from_mode(0o444)).unwrap();
Change {
modified: Some(SystemTime::UNIX_EPOCH),
..Change::default()
}
.apply(&file, false)
.unwrap();
fs::remove_dir_all(&dir).unwrap();
}
}