use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::buffer::Buffer;
use crate::paths;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Separator {
PageBreak,
BlankLines,
HorizontalRule,
None,
}
impl Default for Separator {
fn default() -> Self {
Self::PageBreak
}
}
impl Separator {
#[allow(dead_code)] pub fn as_str(&self) -> &'static str {
match self {
Self::PageBreak => "\n\x0c\n", Self::BlankLines => "\n\n\n\n",
Self::HorizontalRule => "\n# # #\n\n",
Self::None => "",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocEntry {
pub path: PathBuf,
pub title: String,
#[serde(default = "default_true")]
pub include_in_compile: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectManifest {
pub name: String,
pub docs: Vec<DocEntry>,
#[serde(default)]
pub separator: Separator,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)] pub struct SkippedDoc {
pub index: usize,
pub title: String,
pub reason: String,
}
#[derive(Debug, Clone)]
#[allow(dead_code)] pub struct CompileResult {
pub text: String,
pub skipped: Vec<SkippedDoc>,
}
#[derive(Debug)]
#[allow(dead_code)] pub struct Project {
pub manifest_path: PathBuf,
pub manifest: ProjectManifest,
}
impl Project {
#[allow(dead_code)] pub fn load(manifest_path: &Path) -> io::Result<Self> {
let data = std::fs::read_to_string(manifest_path)?;
let mut manifest: ProjectManifest = toml::from_str(&data).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Invalid project manifest: {e}"),
)
})?;
let base = manifest_path
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Manifest has no parent"))?;
for entry in &mut manifest.docs {
if entry.path.is_relative() {
entry.path = base.join(&entry.path);
}
}
Ok(Self {
manifest_path: manifest_path.to_path_buf(),
manifest,
})
}
#[allow(dead_code)] pub fn save(&self) -> io::Result<()> {
let mut manifest = self.manifest.clone();
let base = self
.manifest_path
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Manifest has no parent"))?;
for entry in &mut manifest.docs {
if let Ok(rel) = entry.path.strip_prefix(base) {
entry.path = rel.to_path_buf();
}
}
let data = toml::to_string_pretty(&manifest)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
paths::write_atomic(&self.manifest_path, data.as_bytes())
}
#[allow(dead_code)] pub fn base_dir(&self) -> Option<&Path> {
self.manifest_path.parent()
}
#[allow(dead_code)] pub fn add_doc(&mut self, path: PathBuf, title: String) {
self.manifest.docs.push(DocEntry {
path,
title,
include_in_compile: true,
});
}
#[allow(dead_code)] pub fn remove_doc(&mut self, idx: usize) -> Option<DocEntry> {
if idx < self.manifest.docs.len() {
Some(self.manifest.docs.remove(idx))
} else {
None
}
}
#[allow(dead_code)] pub fn reorder_doc(&mut self, from: usize, to: usize) {
if from < self.manifest.docs.len() && to < self.manifest.docs.len() {
let doc = self.manifest.docs.remove(from);
self.manifest.docs.insert(to, doc);
}
}
#[allow(dead_code)] pub fn doc_exists(&self, idx: usize) -> bool {
self.manifest
.docs
.get(idx)
.map(|e| e.path.exists())
.unwrap_or(false)
}
#[allow(dead_code)] pub fn compile(&self) -> CompileResult {
let sep = self.manifest.separator.as_str();
let mut text = String::new();
let mut skipped: Vec<SkippedDoc> = Vec::new();
let mut first = true;
for (idx, entry) in self.manifest.docs.iter().enumerate() {
if !entry.include_in_compile {
continue;
}
match std::fs::read_to_string(&entry.path) {
Ok(content) => {
if !first && !sep.is_empty() {
text.push_str(sep);
}
text.push_str(&content);
first = false;
}
Err(e) => {
skipped.push(SkippedDoc {
index: idx,
title: entry.title.clone(),
reason: e.to_string(),
});
}
}
}
CompileResult { text, skipped }
}
pub fn doc_word_count(&self, idx: usize) -> Option<usize> {
let entry = self.manifest.docs.get(idx)?;
if !entry.path.exists() {
return None;
}
match Buffer::open(Some(entry.path.clone())) {
Ok(buf) => Some(buf.word_count()),
Err(_) => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("pstar-project-test-{tag}"))
}
#[test]
fn manifest_round_trip() {
let manifest = ProjectManifest {
name: "Test Novel".to_string(),
docs: vec![
DocEntry {
path: PathBuf::from("chapter1.md"),
title: "Chapter One".to_string(),
include_in_compile: true,
},
DocEntry {
path: PathBuf::from("notes.md"),
title: "Research Notes".to_string(),
include_in_compile: false,
},
],
separator: Separator::PageBreak,
};
let toml_str = toml::to_string(&manifest).unwrap();
let manifest2: ProjectManifest = toml::from_str(&toml_str).unwrap();
assert_eq!(manifest2.name, "Test Novel");
assert_eq!(manifest2.docs.len(), 2);
assert_eq!(manifest2.docs[0].title, "Chapter One");
assert!(manifest2.docs[0].include_in_compile);
assert_eq!(manifest2.docs[1].title, "Research Notes");
assert!(!manifest2.docs[1].include_in_compile);
assert_eq!(manifest2.separator, Separator::PageBreak);
}
#[test]
fn save_and_load_manifest() {
let dir = scratch("save-load");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let manifest_path = dir.join("test.pstarproj");
let project = Project {
manifest_path: manifest_path.clone(),
manifest: ProjectManifest {
name: "My Book".to_string(),
docs: vec![DocEntry {
path: dir.join("chapter1.md"),
title: "Chapter 1".to_string(),
include_in_compile: true,
}],
separator: Separator::BlankLines,
},
};
project.save().unwrap();
assert!(manifest_path.exists());
let loaded = Project::load(&manifest_path).unwrap();
assert_eq!(loaded.manifest.name, "My Book");
assert_eq!(loaded.manifest.docs.len(), 1);
assert_eq!(loaded.manifest.docs[0].title, "Chapter 1");
assert_eq!(loaded.manifest.separator, Separator::BlankLines);
assert!(loaded.manifest.docs[0].path.is_absolute());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn relative_paths_stored_in_manifest() {
let dir = scratch("relative-paths");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let manifest_path = dir.join("test.pstarproj");
let project = Project {
manifest_path: manifest_path.clone(),
manifest: ProjectManifest {
name: "Test".to_string(),
docs: vec![DocEntry {
path: dir.join("chapter1.md"), title: "Ch1".to_string(),
include_in_compile: true,
}],
separator: Separator::None,
},
};
project.save().unwrap();
let raw = std::fs::read_to_string(&manifest_path).unwrap();
assert!(
raw.contains("path = \"chapter1.md\"") || raw.contains("path = 'chapter1.md'"),
"Path not stored as relative: {raw}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn add_and_remove_docs() {
let mut project = Project {
manifest_path: PathBuf::from("/tmp/test.pstarproj"),
manifest: ProjectManifest {
name: "Test".to_string(),
docs: vec![],
separator: Separator::PageBreak,
},
};
project.add_doc(PathBuf::from("doc1.md"), "Doc 1".to_string());
project.add_doc(PathBuf::from("doc2.md"), "Doc 2".to_string());
assert_eq!(project.manifest.docs.len(), 2);
let removed = project.remove_doc(0);
assert_eq!(removed.unwrap().title, "Doc 1");
assert_eq!(project.manifest.docs.len(), 1);
assert_eq!(project.manifest.docs[0].title, "Doc 2");
}
#[test]
fn reorder_docs() {
let mut project = Project {
manifest_path: PathBuf::from("/tmp/test.pstarproj"),
manifest: ProjectManifest {
name: "Test".to_string(),
docs: vec![
DocEntry {
path: PathBuf::from("a.md"),
title: "A".to_string(),
include_in_compile: true,
},
DocEntry {
path: PathBuf::from("b.md"),
title: "B".to_string(),
include_in_compile: true,
},
DocEntry {
path: PathBuf::from("c.md"),
title: "C".to_string(),
include_in_compile: true,
},
],
separator: Separator::PageBreak,
},
};
project.reorder_doc(0, 2);
assert_eq!(project.manifest.docs[0].title, "B");
assert_eq!(project.manifest.docs[1].title, "C");
assert_eq!(project.manifest.docs[2].title, "A");
}
#[test]
fn doc_exists_check() {
let dir = scratch("doc-exists");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let existing = dir.join("exists.md");
std::fs::write(&existing, "content").unwrap();
let missing = dir.join("missing.md");
let project = Project {
manifest_path: dir.join("test.pstarproj"),
manifest: ProjectManifest {
name: "Test".to_string(),
docs: vec![
DocEntry {
path: existing,
title: "Existing".to_string(),
include_in_compile: true,
},
DocEntry {
path: missing,
title: "Missing".to_string(),
include_in_compile: true,
},
],
separator: Separator::PageBreak,
},
};
assert!(project.doc_exists(0)); assert!(!project.doc_exists(1));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn separator_as_str() {
assert_eq!(Separator::PageBreak.as_str(), "\n\x0c\n");
assert_eq!(Separator::BlankLines.as_str(), "\n\n\n\n");
assert_eq!(Separator::HorizontalRule.as_str(), "\n# # #\n\n");
assert_eq!(Separator::None.as_str(), "");
}
#[test]
fn doc_word_count_for_existing_file() {
let dir = scratch("word-count");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let doc1 = dir.join("doc1.md");
std::fs::write(&doc1, "Hello world this is a test").unwrap();
let project = Project {
manifest_path: dir.join("test.pstarproj"),
manifest: ProjectManifest {
name: "Test".to_string(),
docs: vec![DocEntry {
path: doc1,
title: "Doc 1".to_string(),
include_in_compile: true,
}],
separator: Separator::PageBreak,
},
};
let wc = project.doc_word_count(0);
assert_eq!(wc, Some(6));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn doc_word_count_for_missing_file() {
let dir = scratch("word-count-missing");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let missing = dir.join("missing.md");
let project = Project {
manifest_path: dir.join("test.pstarproj"),
manifest: ProjectManifest {
name: "Test".to_string(),
docs: vec![DocEntry {
path: missing,
title: "Missing".to_string(),
include_in_compile: true,
}],
separator: Separator::PageBreak,
},
};
let wc = project.doc_word_count(0);
assert_eq!(wc, None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn doc_word_count_for_invalid_index() {
let project = Project {
manifest_path: PathBuf::from("/tmp/test.pstarproj"),
manifest: ProjectManifest {
name: "Test".to_string(),
docs: vec![],
separator: Separator::PageBreak,
},
};
let wc = project.doc_word_count(0);
assert_eq!(wc, None); }
#[test]
fn invalid_manifest_returns_error() {
let dir = scratch("invalid");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let manifest_path = dir.join("bad.pstarproj");
std::fs::write(&manifest_path, "not valid TOML {[}").unwrap();
let result = Project::load(&manifest_path);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Invalid project manifest")
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn atomic_save_preserves_prior_on_failure() {
let dir = scratch("atomic-fail");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let manifest_path = dir.join("test.pstarproj");
let project = Project {
manifest_path: manifest_path.clone(),
manifest: ProjectManifest {
name: "Original".to_string(),
docs: vec![],
separator: Separator::PageBreak,
},
};
project.save().unwrap();
let _original = std::fs::read_to_string(&manifest_path).unwrap();
let mut project = Project::load(&manifest_path).unwrap();
project.manifest.name = "Updated".to_string();
project.save().unwrap();
let updated = std::fs::read_to_string(&manifest_path).unwrap();
assert!(updated.contains("Updated"));
assert!(!updated.contains("Original"));
let mut tmp = manifest_path.clone().into_os_string();
tmp.push(".tmp~");
assert!(!PathBuf::from(tmp).exists(), "Temp file left behind");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn compile_concatenates_docs_with_separator() {
let dir = scratch("compile-basic");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ch1.md"), "Chapter One content.").unwrap();
std::fs::write(dir.join("ch2.md"), "Chapter Two content.").unwrap();
std::fs::write(dir.join("ch3.md"), "Chapter Three content.").unwrap();
let project = Project {
manifest_path: dir.join("test.pstarproj"),
manifest: ProjectManifest {
name: "Novel".to_string(),
docs: vec![
DocEntry {
path: dir.join("ch1.md"),
title: "Chapter 1".to_string(),
include_in_compile: true,
},
DocEntry {
path: dir.join("ch2.md"),
title: "Chapter 2".to_string(),
include_in_compile: true,
},
DocEntry {
path: dir.join("ch3.md"),
title: "Chapter 3".to_string(),
include_in_compile: true,
},
],
separator: Separator::PageBreak,
},
};
let result = project.compile();
assert!(result.skipped.is_empty());
assert_eq!(
result.text,
"Chapter One content.\n\x0c\nChapter Two content.\n\x0c\nChapter Three content."
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn compile_skips_excluded_docs() {
let dir = scratch("compile-exclude");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ch1.md"), "Chapter One.").unwrap();
std::fs::write(dir.join("notes.md"), "Research notes.").unwrap();
std::fs::write(dir.join("ch2.md"), "Chapter Two.").unwrap();
let project = Project {
manifest_path: dir.join("test.pstarproj"),
manifest: ProjectManifest {
name: "Novel".to_string(),
docs: vec![
DocEntry {
path: dir.join("ch1.md"),
title: "Chapter 1".to_string(),
include_in_compile: true,
},
DocEntry {
path: dir.join("notes.md"),
title: "Notes".to_string(),
include_in_compile: false, },
DocEntry {
path: dir.join("ch2.md"),
title: "Chapter 2".to_string(),
include_in_compile: true,
},
],
separator: Separator::BlankLines,
},
};
let result = project.compile();
assert!(result.skipped.is_empty());
assert!(!result.text.contains("Research notes"));
assert_eq!(result.text, "Chapter One.\n\n\n\nChapter Two.");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn compile_skips_missing_files_gracefully() {
let dir = scratch("compile-missing");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ch1.md"), "Chapter One.").unwrap();
std::fs::write(dir.join("ch3.md"), "Chapter Three.").unwrap();
let project = Project {
manifest_path: dir.join("test.pstarproj"),
manifest: ProjectManifest {
name: "Novel".to_string(),
docs: vec![
DocEntry {
path: dir.join("ch1.md"),
title: "Chapter 1".to_string(),
include_in_compile: true,
},
DocEntry {
path: dir.join("ch2.md"),
title: "Chapter 2".to_string(),
include_in_compile: true,
},
DocEntry {
path: dir.join("ch3.md"),
title: "Chapter 3".to_string(),
include_in_compile: true,
},
],
separator: Separator::HorizontalRule,
},
};
let result = project.compile();
assert_eq!(result.skipped.len(), 1);
assert_eq!(result.skipped[0].index, 1);
assert_eq!(result.skipped[0].title, "Chapter 2");
assert_eq!(result.text, "Chapter One.\n# # #\n\nChapter Three.");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn compile_with_no_separator() {
let dir = scratch("compile-no-sep");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("scene1.md"), "Scene one.").unwrap();
std::fs::write(dir.join("scene2.md"), "Scene two.").unwrap();
let project = Project {
manifest_path: dir.join("test.pstarproj"),
manifest: ProjectManifest {
name: "Scenes".to_string(),
docs: vec![
DocEntry {
path: dir.join("scene1.md"),
title: "Scene 1".to_string(),
include_in_compile: true,
},
DocEntry {
path: dir.join("scene2.md"),
title: "Scene 2".to_string(),
include_in_compile: true,
},
],
separator: Separator::None,
},
};
let result = project.compile();
assert!(result.skipped.is_empty());
assert_eq!(result.text, "Scene one.Scene two.");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn compile_empty_project() {
let project = Project {
manifest_path: PathBuf::from("/tmp/test.pstarproj"),
manifest: ProjectManifest {
name: "Empty".to_string(),
docs: vec![],
separator: Separator::PageBreak,
},
};
let result = project.compile();
assert!(result.text.is_empty());
assert!(result.skipped.is_empty());
}
#[test]
fn compile_all_docs_excluded() {
let dir = scratch("compile-all-excluded");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("notes.md"), "Just notes.").unwrap();
let project = Project {
manifest_path: dir.join("test.pstarproj"),
manifest: ProjectManifest {
name: "Notes Only".to_string(),
docs: vec![DocEntry {
path: dir.join("notes.md"),
title: "Notes".to_string(),
include_in_compile: false,
}],
separator: Separator::PageBreak,
},
};
let result = project.compile();
assert!(result.text.is_empty());
assert!(result.skipped.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reorder_persistence_round_trip() {
let dir = scratch("reorder-persist");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.md"), "Alpha").unwrap();
std::fs::write(dir.join("b.md"), "Bravo").unwrap();
std::fs::write(dir.join("c.md"), "Charlie").unwrap();
let manifest_path = dir.join("test.pstarproj");
let mut project = Project {
manifest_path: manifest_path.clone(),
manifest: ProjectManifest {
name: "Reorder Test".to_string(),
docs: vec![
DocEntry {
path: dir.join("a.md"),
title: "A".to_string(),
include_in_compile: true,
},
DocEntry {
path: dir.join("b.md"),
title: "B".to_string(),
include_in_compile: true,
},
DocEntry {
path: dir.join("c.md"),
title: "C".to_string(),
include_in_compile: true,
},
],
separator: Separator::PageBreak,
},
};
project.save().unwrap();
project.reorder_doc(0, 2);
assert_eq!(project.manifest.docs[0].title, "B");
assert_eq!(project.manifest.docs[1].title, "C");
assert_eq!(project.manifest.docs[2].title, "A");
project.save().unwrap();
let loaded = Project::load(&manifest_path).unwrap();
assert_eq!(loaded.manifest.docs.len(), 3);
assert_eq!(loaded.manifest.docs[0].title, "B");
assert_eq!(loaded.manifest.docs[1].title, "C");
assert_eq!(loaded.manifest.docs[2].title, "A");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_succeeds_with_missing_files() {
let dir = scratch("load-missing");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let manifest_path = dir.join("test.pstarproj");
let manifest_toml = r#"
name = "Ghost Project"
separator = "PageBreak"
[[docs]]
path = "nonexistent_chapter1.md"
title = "Ghost Chapter 1"
include_in_compile = true
[[docs]]
path = "nonexistent_chapter2.md"
title = "Ghost Chapter 2"
include_in_compile = true
"#;
std::fs::write(&manifest_path, manifest_toml).unwrap();
let project = Project::load(&manifest_path);
assert!(
project.is_ok(),
"Project::load should succeed with missing doc files"
);
let project = project.unwrap();
assert_eq!(project.manifest.name, "Ghost Project");
assert_eq!(project.manifest.docs.len(), 2);
assert_eq!(project.manifest.docs[0].title, "Ghost Chapter 1");
assert_eq!(project.manifest.docs[1].title, "Ghost Chapter 2");
assert!(project.manifest.docs[0].path.is_absolute());
assert!(!project.manifest.docs[0].path.exists());
assert!(!project.manifest.docs[1].path.exists());
assert!(!project.doc_exists(0));
assert!(!project.doc_exists(1));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn compile_single_doc() {
let dir = scratch("compile-single");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ch1.md"), "Only chapter.").unwrap();
let project = Project {
manifest_path: dir.join("test.pstarproj"),
manifest: ProjectManifest {
name: "Short".to_string(),
docs: vec![DocEntry {
path: dir.join("ch1.md"),
title: "Chapter 1".to_string(),
include_in_compile: true,
}],
separator: Separator::PageBreak,
},
};
let result = project.compile();
assert!(result.skipped.is_empty());
assert_eq!(result.text, "Only chapter.");
let _ = std::fs::remove_dir_all(&dir);
}
}