diff --git a/Cargo.lock b/Cargo.lock index 7cbd6ddb..1487c4f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1076,6 +1076,15 @@ dependencies = [ "plib", ] +[[package]] +name = "posixutils-sccs" +version = "0.1.12" +dependencies = [ + "clap", + "gettext-rs", + "plib", +] + [[package]] name = "posixutils-screen" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index 58a45114..38f8db69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "pathnames", "plib", "process", + "sccs", "screen", "sys", "text", diff --git a/plib/src/lib.rs b/plib/src/lib.rs index c0d84ef9..beab1774 100644 --- a/plib/src/lib.rs +++ b/plib/src/lib.rs @@ -12,6 +12,7 @@ pub mod group; pub mod io; pub mod lzw; pub mod modestr; +pub mod sccsfile; pub mod testing; pub mod utmpx; diff --git a/plib/src/sccsfile.rs b/plib/src/sccsfile.rs new file mode 100644 index 00000000..c521da55 --- /dev/null +++ b/plib/src/sccsfile.rs @@ -0,0 +1,335 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +use std::collections::HashMap; +use std::str::FromStr; + +#[derive(Debug)] +pub struct SccsFile { + pub header: SccsHeader, + pub deltas: Vec, + pub edits: Vec, + pub user_info: SccsUserInfo, + pub stats: SccsStats, +} + +#[derive(Debug)] +pub struct SccsHeader { + pub format_version: String, + pub creation_date: String, + pub author: String, + pub description: String, +} + +#[derive(Debug)] +pub struct SccsDelta { + pub sid: String, + pub date: String, + pub time: String, + pub author: String, + pub added_lines: usize, + pub deleted_lines: usize, + pub comments: String, +} + +#[derive(Debug)] +pub enum SccsEdit { + Insert(String), + Delete(usize), +} + +#[derive(Debug)] +pub struct SccsUserInfo { + pub users: HashMap, +} + +#[derive(Debug)] +pub struct SccsStats { + pub total_deltas: usize, + pub total_lines: usize, +} + +impl SccsFile { + pub fn from_string(s: &str) -> Result { + let lines: Vec<&str> = s.lines().collect(); + + let header = parse_header(&lines)?; + let deltas = parse_deltas(&lines)?; + let edits = parse_edits(&lines)?; + let user_info = parse_user_info(&lines)?; + let stats = parse_stats(&deltas)?; + + Ok(SccsFile { + header, + deltas, + edits, + user_info, + stats, + }) + } + + pub fn serialize(&self) -> String { + serialize_sccs_file(self) + } +} + +fn parse_header(lines: &[&str]) -> Result { + let mut format_version = String::new(); + let mut creation_date = String::new(); + let author = String::new(); + let description = String::new(); + + for line in lines { + if line.starts_with('h') { + format_version = line.to_string(); + } else if line.starts_with('s') && creation_date.is_empty() { + creation_date = line.to_string(); + } else if line.starts_with('d') { + break; // End of header section + } + } + + if format_version.is_empty() || creation_date.is_empty() { + return Err("Missing header"); + } + + Ok(SccsHeader { + format_version, + creation_date, + author, + description, + }) +} + +fn parse_edits(lines: &[&str]) -> Result, &'static str> { + let mut edits = Vec::new(); + let mut current_insert = String::new(); + let mut in_insert_section = false; + + for line in lines.iter() { + if line.starts_with("I ") { + in_insert_section = true; + current_insert.push_str(line); + current_insert.push('\n'); + } else if line.starts_with("E ") && in_insert_section { + current_insert.push_str(line); + current_insert.push('\n'); + edits.push(SccsEdit::Insert(current_insert.clone())); + current_insert.clear(); + in_insert_section = false; + } else if in_insert_section { + current_insert.push_str(line); + current_insert.push('\n'); + } + } + + Ok(edits) +} + +fn parse_user_info(lines: &[&str]) -> Result { + // Simplified user info parsing + let mut users = HashMap::new(); + for line in lines.iter().skip_while(|l| !l.starts_with("users")).skip(1) { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + return Err("Invalid user info format"); + } + users.insert(parts[0].to_string(), parts[1].to_string()); + } + Ok(SccsUserInfo { users }) +} + +fn parse_deltas(lines: &[&str]) -> Result, &'static str> { + let mut deltas = Vec::new(); + let mut current_comments = String::new(); + let mut in_delta_section = false; + + for line in lines.iter() { + if line.starts_with("d D") { + in_delta_section = true; + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 8 { + return Err("Invalid delta format"); + } + deltas.push(SccsDelta { + sid: parts[2].to_string(), + date: parts[3].to_string(), + time: parts[4].to_string(), + author: parts[5].to_string(), + added_lines: usize::from_str(parts[6]).map_err(|_| "Invalid number format")?, + deleted_lines: usize::from_str(parts[7]).map_err(|_| "Invalid number format")?, + comments: String::new(), + }); + } else if in_delta_section { + if line.starts_with("c ") { + current_comments.push_str(&line[2..]); + current_comments.push('\n'); + } else if line.starts_with("e") { + if let Some(last_delta) = deltas.last_mut() { + last_delta.comments = current_comments.clone(); + } + current_comments.clear(); + in_delta_section = false; + } + } + } + + Ok(deltas) +} + +fn parse_stats(deltas: &[SccsDelta]) -> Result { + let total_deltas = deltas.len(); + let total_lines = deltas.iter().map(|d| d.added_lines + d.deleted_lines).sum(); + + Ok(SccsStats { + total_deltas, + total_lines, + }) +} + +fn serialize_sccs_file(sccs_file: &SccsFile) -> String { + let mut result = String::new(); + result.push_str(&sccs_file.header.format_version); + result.push('\n'); + result.push_str(&sccs_file.header.creation_date); + result.push('\n'); + result.push_str(&sccs_file.header.author); + result.push('\n'); + result.push_str(&sccs_file.header.description); + result.push('\n'); + + for delta in &sccs_file.deltas { + result.push_str(&format!( + "d D {} {} {} {} {} {}\n", + delta.sid, delta.date, delta.time, delta.author, delta.added_lines, delta.deleted_lines + )); + if !delta.comments.is_empty() { + result.push_str(&format!("c {}\n", delta.comments.trim_end())); + } + } + + result.push_str("edits\n"); + for edit in &sccs_file.edits { + match edit { + SccsEdit::Insert(line) => result.push_str(&format!("insert {}\n", line)), + SccsEdit::Delete(line_no) => result.push_str(&format!("delete {}\n", line_no)), + } + } + + result.push_str("users\n"); + for (user, info) in &sccs_file.user_info.users { + result.push_str(&format!("{} {}\n", user, info)); + } + + result.push_str(&format!( + "stats total_deltas:{} total_lines:{}\n", + sccs_file.stats.total_deltas, sccs_file.stats.total_lines + )); + + result +} + +#[cfg(test)] +mod sccstest { + use super::*; + + #[test] + fn basic_sccs_file_parse() { + let simple = r#" +h23005 +s 00003/00000/00013 +d D 1.2 24/07/09 19:42:04 jgarzik 2 1 +c added more data +e +s 00013/00000/00000 +d D 1.1 24/07/09 19:38:28 jgarzik 1 0 +c date and time created 24/07/09 19:38:28 by jgarzik +e +u +U +f e 0 +t +T +I 1 +apple +banana +charlie +delta +echo +foxtrot +golf +hotel +india +juliet +kilo +lima +mike +E 1 +I 2 +november +october +pauly +E 2 +"#; + + let sccs_file = SccsFile::from_string(simple).expect("Failed to parse SCCS file"); + + // Verify header + assert_eq!(sccs_file.header.format_version, "h23005"); + assert_eq!(sccs_file.header.creation_date, "s 00003/00000/00013"); + assert_eq!(sccs_file.header.author, ""); + assert_eq!(sccs_file.header.description, ""); + + // Verify deltas + assert_eq!(sccs_file.deltas.len(), 2); + + assert_eq!(sccs_file.deltas[0].sid, "1.2"); + assert_eq!(sccs_file.deltas[0].date, "24/07/09"); + assert_eq!(sccs_file.deltas[0].time, "19:42:04"); + assert_eq!(sccs_file.deltas[0].author, "jgarzik"); + assert_eq!(sccs_file.deltas[0].added_lines, 2); + assert_eq!(sccs_file.deltas[0].deleted_lines, 1); + assert_eq!(sccs_file.deltas[0].comments, "added more data\n"); + + assert_eq!(sccs_file.deltas[1].sid, "1.1"); + assert_eq!(sccs_file.deltas[1].date, "24/07/09"); + assert_eq!(sccs_file.deltas[1].time, "19:38:28"); + assert_eq!(sccs_file.deltas[1].author, "jgarzik"); + assert_eq!(sccs_file.deltas[1].added_lines, 1); + assert_eq!(sccs_file.deltas[1].deleted_lines, 0); + assert_eq!( + sccs_file.deltas[1].comments, + "date and time created 24/07/09 19:38:28 by jgarzik\n" + ); + + // Verify edits + assert_eq!(sccs_file.edits.len(), 2); + + match &sccs_file.edits[0] { + SccsEdit::Insert(line) => { + assert_eq!(line, "I 1\napple\nbanana\ncharlie\ndelta\necho\nfoxtrot\ngolf\nhotel\nindia\njuliet\nkilo\nlima\nmike\nE 1\n"); + } + _ => panic!("Unexpected edit type"), + } + + match &sccs_file.edits[1] { + SccsEdit::Insert(line) => { + assert_eq!(line, "I 2\nnovember\noctober\npauly\nE 2\n"); + } + _ => panic!("Unexpected edit type"), + } + + // Verify user info + assert_eq!(sccs_file.user_info.users.len(), 0); + + // Verify stats + assert_eq!(sccs_file.stats.total_deltas, 2); + assert_eq!(sccs_file.stats.total_lines, 4); + } +} diff --git a/sccs/Cargo.toml b/sccs/Cargo.toml new file mode 100644 index 00000000..4395ba77 --- /dev/null +++ b/sccs/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "posixutils-sccs" +version = "0.1.12" +edition = "2021" +authors = ["Jeff Garzik"] +license = "MIT" +repository = "https://github.com/rustcoreutils/posixutils-rs.git" + +[dependencies] +plib = { path = "../plib" } +clap.workspace = true +gettext-rs.workspace = true + +[[bin]] +name = "what" +path = "src/what.rs" + diff --git a/sccs/src/what.rs b/sccs/src/what.rs new file mode 100644 index 00000000..8586a823 --- /dev/null +++ b/sccs/src/what.rs @@ -0,0 +1,74 @@ +// +// Copyright (c) 2024 Jeff Garzik +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +extern crate clap; +extern crate plib; + +use clap::Parser; +use gettextrs::{bind_textdomain_codeset, gettext, setlocale, textdomain, LocaleCategory}; +use plib::PROJECT_NAME; +use std::fs::File; +use std::io::{self, BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +/// what — identify SCCS files +#[derive(Parser)] +#[command(author, version, about, long_about)] +struct Args { + /// Display at most one identification string per file + #[arg(short = 's', long)] + single: bool, + + /// Input files + files: Vec, +} + +fn process_file(reader: R, single: bool) -> io::Result<()> { + let mut found = false; + for line in reader.lines() { + let line = line?; + let mut start = 0; + while let Some(pos) = line[start..].find("@(#)") { + if found && single { + return Ok(()); + } + let rest = &line[start + pos + 4..]; + if let Some(end) = + rest.find(|c| c == '"' || c == '>' || c == '\n' || c == '\\' || c == '\0') + { + println!("@(#){}", &rest[..end]); + } else { + println!("@(#){}", rest); + } + found = true; + start += pos + 4; + } + } + Ok(()) +} + +fn main() -> io::Result<()> { + let args = Args::parse(); + + setlocale(LocaleCategory::LcAll, ""); + textdomain(PROJECT_NAME)?; + bind_textdomain_codeset(PROJECT_NAME, "UTF-8")?; + + for file in &args.files { + let path = Path::new(file); + if let Ok(file) = File::open(&path) { + let reader = BufReader::new(file); + process_file(reader, args.single)?; + } else { + eprintln!("what: {}: {}", gettext("Cannot open file"), file.display()); + } + } + + Ok(()) +} diff --git a/sccs/tests/empty_file.txt b/sccs/tests/empty_file.txt new file mode 100644 index 00000000..e69de29b diff --git a/sccs/tests/multiple_identifications.txt b/sccs/tests/multiple_identifications.txt new file mode 100644 index 00000000..cace737b --- /dev/null +++ b/sccs/tests/multiple_identifications.txt @@ -0,0 +1,3 @@ +@(#)first_identification +Some text +@(#)second_identification diff --git a/sccs/tests/no_identification.txt b/sccs/tests/no_identification.txt new file mode 100644 index 00000000..0a3a4148 --- /dev/null +++ b/sccs/tests/no_identification.txt @@ -0,0 +1 @@ +This file has no identification strings. diff --git a/sccs/tests/single_identification.txt b/sccs/tests/single_identification.txt new file mode 100644 index 00000000..14f78533 --- /dev/null +++ b/sccs/tests/single_identification.txt @@ -0,0 +1 @@ +@(#)single_identification diff --git a/sccs/tests/single_identification_flag.txt b/sccs/tests/single_identification_flag.txt new file mode 100644 index 00000000..cace737b --- /dev/null +++ b/sccs/tests/single_identification_flag.txt @@ -0,0 +1,3 @@ +@(#)first_identification +Some text +@(#)second_identification diff --git a/sccs/tests/special_characters.txt b/sccs/tests/special_characters.txt new file mode 100644 index 00000000..8ad9a8a5 Binary files /dev/null and b/sccs/tests/special_characters.txt differ diff --git a/sccs/tests/what-tests.rs b/sccs/tests/what-tests.rs new file mode 100644 index 00000000..6f2dcc88 --- /dev/null +++ b/sccs/tests/what-tests.rs @@ -0,0 +1,83 @@ +// +// Copyright (c) 2024 Hemi Labs, Inc. +// +// This file is part of the posixutils-rs project covered under +// the MIT License. For the full license text, please see the LICENSE +// file in the root directory of this project. +// SPDX-License-Identifier: MIT +// + +use plib::{run_test, TestPlan}; +use std::path::PathBuf; + +fn test_file_path(file_name: &str) -> PathBuf { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests"); + path.push(file_name); + path +} + +fn generate_test_plan(args: Vec<&str>, expected_out: &str) -> TestPlan { + TestPlan { + cmd: String::from("what"), + args: args.iter().map(|&s| String::from(s)).collect(), + stdin_data: String::new(), + expected_out: String::from(expected_out), + expected_err: String::new(), + expected_exit_code: 0, + } +} + +#[test] +fn test_single_identification() { + let file_path = test_file_path("single_identification.txt"); + let plan = generate_test_plan( + vec![file_path.to_str().unwrap()], + "@(#)single_identification\n", + ); + run_test(plan); +} + +#[test] +fn test_multiple_identifications() { + let file_path = test_file_path("multiple_identifications.txt"); + let plan = generate_test_plan( + vec![file_path.to_str().unwrap()], + "@(#)first_identification\n@(#)second_identification\n", + ); + run_test(plan); +} + +#[test] +fn test_single_identification_flag() { + let file_path = test_file_path("single_identification_flag.txt"); + let plan = generate_test_plan( + vec!["-s", file_path.to_str().unwrap()], + "@(#)first_identification\n", + ); + run_test(plan); +} + +#[test] +fn test_no_identification() { + let file_path = test_file_path("no_identification.txt"); + let plan = generate_test_plan(vec![file_path.to_str().unwrap()], ""); + run_test(plan); +} + +#[test] +fn test_empty_file() { + let file_path = test_file_path("empty_file.txt"); + let plan = generate_test_plan(vec![file_path.to_str().unwrap()], ""); + run_test(plan); +} + +#[test] +fn test_special_characters() { + let file_path = test_file_path("special_characters.txt"); + let plan = generate_test_plan( + vec![file_path.to_str().unwrap()], + "@(#)special\n@(#)another\n@(#)back\n@(#)null\n", + ); + run_test(plan); +}