use std::io;
use std::path::{Path, PathBuf};
use crate::app::PreviewFormat;
#[derive(Debug)]
pub struct Document {
pub path: Option<PathBuf>,
pub text: String,
pub format: PreviewFormat,
pub origin: Option<PathBuf>,
}
impl Document {
pub fn empty() -> Self {
Self {
path: None,
text: String::new(),
format: PreviewFormat::Markdown,
origin: None,
}
}
pub fn read(path: PathBuf) -> io::Result<Self> {
let text = std::fs::read_to_string(&path)?;
let format = PreviewFormat::for_path(&path);
Ok(Self {
path: Some(path),
text,
format,
origin: None,
})
}
pub fn open(path: PathBuf) -> io::Result<Self> {
match Self::read(path.clone()) {
Err(err) if err.kind() == io::ErrorKind::NotFound && parent_exists(&path) => {
let format = PreviewFormat::for_path(&path);
Ok(Self {
path: Some(path),
text: String::new(),
format,
origin: None,
})
}
read => read,
}
}
pub fn import(path: PathBuf, format: crate::convert::Format) -> anyhow::Result<Self> {
let text = crate::convert::to_markdown(&path, format)?;
Ok(Self {
path: None,
text,
format: PreviewFormat::Markdown,
origin: Some(path),
})
}
pub fn display_name(path: Option<&Path>) -> String {
path.and_then(Path::file_name)
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "Untitled".to_string())
}
}
fn parent_exists(path: &Path) -> bool {
match path.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir.is_dir(),
_ => true,
}
}
pub fn write(path: &Path, text: &str) -> io::Result<()> {
std::fs::write(path, text)
}
pub fn path_from_file_url(url: &str) -> Option<PathBuf> {
let rest = url.strip_prefix("file://")?;
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
if !rest.starts_with('/') {
return None;
}
let decoded = percent_decode(rest)?;
let decoded = match decoded.as_bytes() {
[b'/', drive, b':', b'/', ..] if drive.is_ascii_alphabetic() && cfg!(windows) => {
decoded[1..].to_string()
}
_ => decoded,
};
Some(PathBuf::from(decoded))
}
fn percent_decode(text: &str) -> Option<String> {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hex = text.get(i + 1..i + 3)?;
out.push(u8::from_str_radix(hex, 16).ok()?);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_name_falls_back_to_untitled() {
assert_eq!(Document::display_name(None), "Untitled");
assert_eq!(
Document::display_name(Some(Path::new("/tmp/notes/todo.md"))),
"todo.md"
);
}
#[test]
fn read_keeps_crlf_and_write_round_trips() {
let dir = std::env::temp_dir().join(format!("smep-io-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("crlf.md");
std::fs::write(&path, "a\r\nb\r\n").unwrap();
let doc = Document::read(path.clone()).unwrap();
assert_eq!(doc.text, "a\r\nb\r\n");
assert_eq!(doc.format, PreviewFormat::Markdown);
write(&path, &doc.text).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"a\r\nb\r\n");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn file_urls_become_paths() {
assert_eq!(
path_from_file_url("file:///Users/k/notes/a%20b.md"),
Some(PathBuf::from("/Users/k/notes/a b.md"))
);
assert_eq!(
path_from_file_url("file://localhost/tmp/%E4%B8%AD%E6%96%87.md"),
Some(PathBuf::from("/tmp/中文.md"))
);
assert_eq!(path_from_file_url("https://example.com/a.md"), None);
assert_eq!(path_from_file_url("file://host/a.md"), None);
assert_eq!(path_from_file_url("file:///bad%zz"), None);
if cfg!(windows) {
assert_eq!(
path_from_file_url("file:///C:/docs/a.md"),
Some(PathBuf::from("C:/docs/a.md"))
);
}
}
#[test]
fn open_starts_a_new_document_for_a_missing_file_in_an_existing_directory() {
let dir = std::env::temp_dir().join(format!("smep-io-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let fresh = dir.join("new.html");
let doc = Document::open(fresh.clone()).unwrap();
assert_eq!(doc.path, Some(fresh.clone()));
assert_eq!(doc.text, "");
assert_eq!(
doc.format,
PreviewFormat::Html,
"the name decides the format"
);
assert!(doc.origin.is_none());
assert!(!fresh.exists(), "nothing is written until the first save");
std::fs::write(dir.join("old.md"), "kept").unwrap();
assert_eq!(Document::open(dir.join("old.md")).unwrap().text, "kept");
let err = Document::open(dir.join("no-such-dir").join("x.md")).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn read_rejects_invalid_utf8() {
let dir = std::env::temp_dir().join(format!("smep-io-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("bad.md");
std::fs::write(&path, [0x23, 0x20, 0xC3, 0x28]).unwrap();
let err = match Document::read(path) {
Ok(_) => panic!("invalid UTF-8 must be rejected"),
Err(err) => err,
};
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
std::fs::remove_dir_all(&dir).unwrap();
}
}