main.rs
1 use std::{fs, path::PathBuf}; 2 3 use anyhow::{Context, Result, anyhow}; 4 use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; 5 use clap_complete::{Shell, generate_to}; 6 use dos_lib::{document::Document, open_connection_file, revision::Revision}; 7 use rusqlite::Connection; 8 use time::{OffsetDateTime, macros::format_description}; 9 10 /// Command line interface to the diff of services tool 11 #[derive(Parser)] 12 #[command(name = "dos", author, version, about, long_about = None)] 13 struct Cli { 14 /// Every invocation calls a command 15 #[command(subcommand)] 16 command: Command, 17 } 18 19 /// Various top level commands 20 #[derive(Subcommand)] 21 enum Command { 22 /// # Utility commands 23 /// 24 /// At this point, mainly for generating files 25 /// such as shell completion and man pages 26 Util { 27 /// Which utility action should be performed 28 #[command(subcommand)] 29 action: UtilAction, 30 }, 31 /// # List Commands 32 /// 33 /// The List commands list various aspects of the data that 34 /// currently resides in the database. What action listing 35 /// is performed is based on the presence or absence of 36 /// target document or revision. If there are no documents 37 /// listed, we simply list of the collection of documents, 38 /// showing the number of revisions of the document and when 39 /// it was last updated. If just a document but no revision 40 /// is found, then we list the revisions and their added date 41 /// for the requested document. If both the document and 42 /// revision are specified, then we show the metadata of the 43 /// document and revision, as well as the text of the revision. 44 List { 45 /// The document that is being queried 46 document: Option<String>, 47 /// The revision that is being queried 48 revision: Option<u32>, 49 }, 50 /// # Create Command 51 /// 52 /// The create command creates a document with the given name. 53 /// If the text argument is passed, then a default revision is 54 /// created with the text contents being the contents of the file 55 /// specified by the text parameter. 56 Create { 57 /// Name of the new document to be created 58 name: String, 59 /// Path to a file containing the text of the first revision 60 text: Option<PathBuf>, 61 }, 62 /// # Update command 63 /// 64 /// Updates the document named `name' by adding a new revision 65 /// with a body as supplied by the file specified by `path' 66 Update { name: String, path: PathBuf }, 67 /// # Diff command 68 /// 69 /// Prints a diff of the two stated revisions for the chosen 70 /// document. If no revisions are listed, they are assumed 71 /// to be the latest and the second latest. 72 Diff { 73 name: String, 74 #[arg(num_args = 2)] 75 revs: Option<Vec<u32>>, 76 }, 77 } 78 79 /// What utility action is to be performed 80 #[derive(Subcommand)] 81 enum UtilAction { 82 /// Generate some system utility files. Currently supports generating 83 /// shell completions (bash/zsh/fish) and manpages 84 Generate { 85 /// Type of thing to be generated 86 #[arg(value_enum)] 87 kind: Generated, 88 /// Path to the directory into which to put the 89 /// generated files. Has defaults as a new subdirectory 90 /// of the current directory 91 out: Option<PathBuf>, 92 }, 93 } 94 95 /// What type of generation is to occur 96 #[derive(Clone, ValueEnum)] 97 enum Generated { 98 /// Generate the manpages 99 Manpages, 100 /// Generate the shell completions 101 ShellCompletions, 102 } 103 104 fn main() -> Result<()> { 105 let cli = Cli::parse(); 106 match cli.command { 107 Command::Util { action } => match action { 108 UtilAction::Generate { kind, out } => match kind { 109 Generated::Manpages => { 110 // Generate manpages, default at ./main 111 let out = match out { 112 Some(path) => path, 113 None => PathBuf::from("./man"), 114 }; 115 116 let s = out.display(); 117 118 println!("generating manpages to {s}"); 119 120 // Create directory (ensures that it does not already exist) 121 // prior to generating manpages 122 fs::create_dir(&out).with_context(|| { 123 format!("creating {} for manpages", out.to_string_lossy()) 124 })?; 125 // Generate the manpages 126 clap_mangen::generate_to(Cli::command(), &out).with_context(|| { 127 format!("generating manpages to {}", out.to_string_lossy()) 128 })?; 129 } 130 Generated::ShellCompletions => { 131 // Generate shell completions, into ./completions 132 let out = match out { 133 Some(path) => path, 134 None => PathBuf::from("./completions"), 135 }; 136 137 let s = out.display(); 138 139 println!("generating shell completions to {s}"); 140 141 // Generate completions, ensuring directory exists. 142 // If any error occurs in the process, skip the rest 143 // of the actions / completions. 144 fs::create_dir(&out).with_context(|| { 145 format!("creating {} for shell completions", out.to_string_lossy()) 146 })?; 147 for &shell in Shell::value_variants() { 148 generate_to(shell, &mut Cli::command(), "dos", &out).with_context( 149 || { 150 format!( 151 "generating shell completion for {} into {}", 152 shell, 153 out.to_string_lossy() 154 ) 155 }, 156 )?; 157 } 158 } 159 }, 160 }, 161 Command::List { document, revision } => { 162 // List the documents / document's revision / revision's data 163 let conn = open_connection_file().context("opening database")?; 164 165 if let Some(document) = document { 166 if let Some(revision) = revision { 167 list_revision(document, revision, &conn) 168 } else { 169 list_revisions(document, &conn) 170 } 171 } else { 172 list_documents(&conn) 173 } 174 .context("running listing")?; 175 } 176 Command::Create { name, text } => { 177 // Ensure the path exists before we start creating the document 178 if let Some(path) = &text 179 && !path.exists() 180 { 181 Err(anyhow!("{} does not exist", path.to_string_lossy()))?; 182 } 183 184 let conn = open_connection_file().context("opening database")?; 185 // Create a new document 186 let mut doc = Document::insert(&name, &conn) 187 .with_context(|| format!("inserting document named {name}"))?; 188 189 // If the user provided text of the new document 190 // and the file exists, then add that as a new revision 191 // on the created document 192 if let Some(p) = &text { 193 // Fetch the requested file 194 let contents = fs::read_to_string(p).with_context(|| { 195 format!( 196 "reading revision's text contents at {}", 197 p.to_string_lossy() 198 ) 199 })?; 200 201 // Add a new revision upon the created document 202 doc.add_new_revision(&contents, &conn) 203 .context("updating with new revision")?; 204 } 205 println!("Created document named {name}"); 206 } 207 Command::Update { name, path } => { 208 let conn = open_connection_file().context("opening database")?; 209 210 // Ensure document exists 211 let mut doc = Document::from_name(&name, &conn) 212 .with_context(|| format!("fetching document named {name}"))?; 213 // Ensure new revision exists 214 let contents = fs::read_to_string(&path).with_context(|| { 215 format!("reading update text contents at {}", path.to_string_lossy()) 216 })?; 217 218 // Add new revision to the document 219 doc.add_new_revision(&contents, &conn) 220 .context("updating document with new revision")?; 221 println!( 222 "Added revision {} to document {name}", 223 path.to_string_lossy() 224 ); 225 } 226 Command::Diff { name, revs } => { 227 let conn = open_connection_file().context("opening database")?; 228 229 let doc = Document::from_name(&name, &conn).context("fetching document")?; 230 231 let (rev1, rev2) = if let Some(revs) = revs { 232 let rev1 = revs[0]; 233 let rev2 = revs[1]; 234 235 ( 236 Revision::from_id(rev1, &conn).context("fetching first revision")?, 237 Revision::from_id(rev2, &conn).context("fetching second revision")?, 238 ) 239 } else { 240 let mut revs = doc.revisions(&conn).context("fetching all revisions")?; 241 revs.sort_by(|l, r| r.added_on().cmp(&l.added_on())); 242 (revs[1].clone(), revs[0].clone()) 243 }; 244 245 let (rev1, rev2) = (rev1.load_text(&conn)?, rev2.load_text(&conn)?); 246 247 for diff in diff::lines(&rev1.text.unwrap(), &rev2.text.unwrap()) { 248 match diff { 249 diff::Result::Left(l) => println!("-{l}"), 250 diff::Result::Both(l, _) => println!("{l}"), 251 diff::Result::Right(r) => println!("+{r}"), 252 } 253 } 254 } 255 }; 256 257 Ok(()) 258 } 259 260 /// Print to stdout all of the documents with some corresponding metadata 261 fn list_documents(conn: &Connection) -> Result<()> { 262 // Get all documents 263 let docs = Document::get_all(conn).context("Fetching all documents")?; 264 265 // Iterate over then all, printing name, number of revisions, 266 // and localized time of the most recent revision 267 for doc in docs.iter() { 268 let name = doc.name(); 269 let num_revisions = doc 270 .count_revisions(conn) 271 .with_context(|| format!("Counting revisions for {name}"))?; 272 match doc.last_updated(conn) { 273 Some(t) => { 274 let t = t.with_context(|| format!("fetching last updated time for {name}"))?; 275 let formatted = format_local_time(t); 276 println!("{name}: {num_revisions} revisions, last updated {formatted}"); 277 } 278 None => println!("{name}: {num_revisions} revisions, last updated never"), 279 }; 280 } 281 282 let count = docs.len(); 283 println!("{count} documents"); 284 285 Ok(()) 286 } 287 288 /// List all of the revisions for the document with the provided name 289 fn list_revisions(name: String, conn: &Connection) -> Result<()> { 290 // Fetch document by name. Could fail if they gave a bad name 291 let document = Document::from_name(&name, conn) 292 .with_context(|| format!("fetching document with name {name}"))?; 293 // Get the revisions of the document. 294 let revisions = document 295 .revisions(conn) 296 .with_context(|| format!("fetching revisions for {name}"))?; 297 298 // List the revisions with indices 299 for (idx, revision) in revisions.iter().enumerate().rev() { 300 let formatted = format_local_time(revision.added_on()); 301 println!("Revision #{idx} created on {formatted}") 302 } 303 304 Ok(()) 305 } 306 307 /// List the information for a given document's revision 308 fn list_revision(name: String, nth: u32, conn: &Connection) -> Result<()> { 309 // Ensure document exists 310 let document = Document::from_name(&name, conn) 311 .with_context(|| format!("fetching document named {name}"))?; 312 // Ensure revision exists 313 let revision = document 314 .nth_revision(nth, conn) 315 .with_context(|| format!("fetching revision {nth} for {name}"))?; 316 // Load the revision's text 317 let revision = revision 318 .load_text(conn) 319 .with_context(|| format!("fetching text for {nth} revision of {name}"))?; 320 321 // Print metadata 322 let name = document.name(); 323 println!("Document: {name}"); 324 println!("Revision: {nth}"); 325 let formatted = format_local_time(revision.added_on()); 326 println!("Added: {formatted}"); 327 println!("Text:"); 328 let text = revision 329 .text 330 .ok_or(anyhow!("revision's text was missing"))?; 331 println!("{text}"); 332 333 Ok(()) 334 } 335 336 /// Formats the given time as a string. Shows date/time down to the minute 337 fn format_local_time(time: OffsetDateTime) -> String { 338 let formatter = format_description!("[year]-[month]-[day] at [hour]:[minute]"); 339 time.format(formatter).unwrap() 340 }