/ src / filemarks.rs
filemarks.rs
  1  //
  2  // This file is part of filemark.
  3  //
  4  // filemark is free software: you can redistribute it and/or modify it under the
  5  // terms of the GNU General Public License as published by the Free Software
  6  // Foundation, either version 3 of the License, or (at your option) any later
  7  // version.
  8  //
  9  // filemark is distributed in the hope that it will be useful, but WITHOUT ANY
 10  // WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 11  // A PARTICULAR PURPOSE. See the GNU General Public License for more details.
 12  //
 13  // You should have received a copy of the GNU General Public License along with
 14  // filemark. If not, see <https://www.gnu.org/licenses/>.
 15  //
 16  use std::path::{Path, PathBuf};
 17  use std::error::Error;
 18  use std::fmt;
 19  use std::io::{BufRead, Read};
 20  
 21  #[derive(Debug, PartialEq)]
 22  pub enum FilemarkError {
 23      InvalidUrlRel(String),
 24      InvalidLines(String),
 25      InvalidLinesRange(usize, usize),
 26      FileMissing(String),
 27      LinesMissing(String),
 28      FileHashMismatch(String, String),
 29  }
 30  
 31  impl fmt::Display for FilemarkError {
 32      fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
 33          use FilemarkError::*;
 34          match self {
 35              InvalidUrlRel(url) => write!(f, "File mark URL needs an absolute path: {}", url),
 36              InvalidLines(lines) => write!(f, "File mark has invalid lines={}", lines),
 37              InvalidLinesRange(s, e) => write!(f, "File mark has invalid range lines={}-{}", s, e),
 38              FileMissing(url) => write!(f, "File mark URL lacks file: {}", url),
 39              LinesMissing(url) => write!(f, "File mark URL lacks lines: {}", url),
 40              FileHashMismatch(expected, digest)
 41                  => write!(f, "file hash mismatch, expected {} got {}", expected, digest),
 42          }
 43      }
 44  }
 45  
 46  impl std::error::Error for FilemarkError {}
 47  
 48  fn parse_lines(lines: &str) -> Result<(usize, usize), FilemarkError> {
 49      if let Some((start, end)) = lines.split_once('-') {
 50          let start = usize::from_str_radix(start, 10)
 51              .map_err(|_| FilemarkError::InvalidLines(start.to_string()))?;
 52          let end = usize::from_str_radix(end, 10)
 53              .map_err(|_| FilemarkError::InvalidLines(end.to_string()))?;
 54  
 55          Ok((start, end))
 56      } else {
 57          let line = usize::from_str_radix(lines, 10)
 58              .map_err(|_| FilemarkError::InvalidLines(lines.to_string()))?;
 59          Ok((line, line))
 60      }
 61  }
 62  
 63  fn num_width(mut n: usize) -> usize {
 64      let mut res = 1;
 65      while n > 9 {
 66          res +=1;
 67          n = n / 10;
 68      }
 69      res
 70  }
 71  
 72  #[derive(Debug)]
 73  enum HashType {
 74      Md5,
 75  }
 76  
 77  #[derive(Debug)]
 78  pub struct Filemark {
 79      file: PathBuf,
 80      lines: (usize, usize),
 81      fhash: Option<(HashType, String)>,
 82      link: bool,
 83      description: Option<String>,
 84  }
 85  
 86  impl Filemark {
 87      fn from_url(url: &url::Url) -> Result<Self, FilemarkError> {
 88          let mut file = None;
 89          let mut fhash = None;
 90          let mut lines = None;
 91          let mut link = false;
 92          let mut description = None;
 93          for (k,v) in url.query_pairs() {
 94              match k.as_ref() {
 95                  "file" => {
 96                      file = Some(PathBuf::from(v.as_ref()));
 97                  }
 98                  "lines" => {
 99                      lines = Some(parse_lines(&v)?);
100                  }
101                  "md5" => {
102                      fhash = Some((HashType::Md5, v.to_lowercase()));
103                  }
104                  "link" => {
105                      link = true;
106                  }
107                  "description" => {
108                      description = Some(v.to_string());
109                  }
110                  _ => (),
111              }
112          }
113  
114          let file = file.ok_or(FilemarkError::FileMissing(url.to_string()))?;
115          if file.is_relative() {
116              return Err(FilemarkError::InvalidUrlRel(url.to_string()).into());
117          }
118  
119          let lines = lines.ok_or_else(|| FilemarkError::LinesMissing(url.as_str().to_string()))?;
120          if lines.0 < 1 || lines.1 < lines.0 {
121              return Err(FilemarkError::InvalidLinesRange(lines.0, lines.1).into())
122          }
123  
124          Ok(Filemark { file, lines, fhash, link, description })
125      }
126  }
127  
128  pub enum MarkRender {
129      Text(String, String),
130      Link(String, Option<String>),
131  }
132  
133  /// Read a filemark url
134  pub fn read_mark<P: AsRef<Path>>(root: P, url: &url::Url, numberlines: bool, linedesc: bool) -> Result<MarkRender, Box<dyn Error>> {
135      let mut path = root.as_ref().to_path_buf().canonicalize()?;
136      use std::path::Component::*;
137  
138      let mark = Filemark::from_url(url)?;
139      for comp in mark.file.components() {
140          match comp {
141              Prefix(_) | RootDir => (),
142              comp => path.push(comp),
143          }
144      }
145  
146      if let Some((HashType::Md5, hash)) = mark.fhash {
147          let mut data = Vec::new();
148          std::fs::File::open(&path)?.read_to_end(&mut data)?;
149          let digest = format!("{:x}", md5::compute(&data));
150          if digest != hash {
151              return Err(FilemarkError::FileHashMismatch(hash, digest).into());
152          }
153      }
154  
155      let f = std::io::BufReader::new(std::fs::File::open(&path)?);
156      let iter = f.lines().enumerate()
157          .skip(mark.lines.0-1)
158          .take(mark.lines.1 - mark.lines.0 + 1)
159          .map(|(lnum, line)| (lnum+1, line));
160  
161      if mark.link {
162          let url = "file://".to_owned() + &path.to_string_lossy();
163          Ok(MarkRender::Link(url, mark.description))
164      } else {
165          let mut output = String::new();
166          let mut iter = iter.peekable();
167          let maxwidth = num_width(mark.lines.1);
168          while let Some((num, data)) = iter.next() {
169              let data = data?;
170              if numberlines {
171                  output.push_str(&format!("{:1$}:", num, maxwidth));
172                  if !data.is_empty() {
173                      output.push_str(" ");
174                  }
175              }
176              output.push_str(&data);
177              if iter.peek().is_some() {
178                  output.push_str("\n");
179              }
180          }
181  
182          let description = match mark.description {
183              Some(d) => d,
184              None if linedesc => {
185                  let p = mark.file.to_string_lossy();
186                  format!("{}:{},{}",
187                          p.strip_prefix('/').unwrap_or(&p),
188                          mark.lines.0,
189                          mark.lines.1)
190              }
191              None => url.to_string(),
192          };
193  
194          Ok(MarkRender::Text(output, description))
195      }
196  }
197  
198  #[cfg(test)]
199  mod tests {
200      use super::*;
201      use FilemarkError::*;
202      use assert_matches::assert_matches;
203  
204      fn mark(s: &str) -> Result<Filemark, FilemarkError> {
205          let u = url::Url::parse(s).unwrap();
206          Filemark::from_url(&u)
207      }
208  
209      #[test]
210      fn test_parse_lines_single() {
211          assert_eq!(parse_lines("33"), Ok((33, 33)))
212      }
213      #[test]
214      fn test_parse_lines_range() {
215          assert_eq!(parse_lines("1-3"), Ok((1, 3)))
216      }
217      #[test]
218      fn test_parse_lines_invalid() {
219          assert!(parse_lines("-3").is_err())
220      }
221  
222      #[test]
223      fn test_num_width() {
224          assert_eq!(num_width(1), 1);
225          assert_eq!(num_width(0), 1);
226          assert_eq!(num_width(10), 2);
227          assert_eq!(num_width(100), 3);
228      }
229  
230      #[test]
231      fn test_mark_missing_file() {
232          let m = mark("filemark:?lines=3");
233          assert_matches!(m, Err(FileMissing(..))); 
234      }
235      #[test]
236      fn test_mark_missing_lines() {
237          let m = mark("filemark:?file=/filename");
238          assert_matches!(m, Err(LinesMissing(..))); 
239      }
240      #[test]
241      fn test_mark_rel_file() {
242          let m = mark("filemark:?file=filename&lines=3");
243          assert_matches!(m, Err(InvalidUrlRel(..))); 
244      }
245      #[test]
246      fn test_mark_range_0() {
247          let m = mark("filemark:?file=/filename&lines=0");
248          assert_matches!(m, Err(InvalidLinesRange(..))); 
249      }
250  }