/ dos-cli / src / main.rs
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  }