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 }