Skip to content

Commit e3ba074

Browse files
timabellclaude
andcommitted
Refactor to clean architecture with layered separation of concerns
Implement comprehensive architectural restructuring for better testability and maintainability: **Domain Layer** (): - Models: Document, OutlineItem with rich domain methods - Parsing: MarkdownParser trait with PulldownMarkdownParser implementation - Services: FileService trait defining data access contracts **Infrastructure Layer** (): - RealFileService: Actual filesystem operations - MockFileService: Test doubles for unit testing **Application Layer** (): - DocumentService: Coordinates domain and infrastructure - ApplicationServices: Main service composition and dependency injection **Presentation Layer** (): - App: Main UI component using services via context - Components: FileItem, MainPanel, OutlineItemComponent - Styles: Extracted Solarized theme CSS **Benefits**: - TDD-friendly: Business logic testable without GUI dependencies - Outside-in testing: Complete workflows testable with mocks - Dependency injection: Services use trait abstractions for easy testing - Scalability: Clear places for new features, plugin architecture ready - Maintainability: Proper separation of concerns across layers **Backwards Compatibility**: Legacy API maintained through lib.rs re-exports **Test Coverage**: 13 comprehensive tests across all architectural layers 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d26cf2f commit e3ba074

27 files changed

Lines changed: 917 additions & 348 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ repository = "https://github.com/timabell/markdown-neuraxis"
1111
pulldown-cmark = "0.11"
1212
serde = { version = "1.0", features = ["derive"] }
1313
dioxus = { version = "0.6", features = ["desktop"] }
14+
thiserror = "1.0"
1415

1516
[dev-dependencies]
1617
rstest = "0.23"

src/app/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub mod services;
2+
3+
pub use services::*;

src/app/services.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
use crate::domain::models::{Document, FileEntry, NotesStructure};
2+
use crate::domain::parsing::MarkdownParser;
3+
use crate::domain::services::{FileService, FileServiceError};
4+
use crate::infrastructure::RealFileService;
5+
use std::path::{Path, PathBuf};
6+
use std::sync::Arc;
7+
8+
#[derive(Debug, thiserror::Error)]
9+
pub enum DocumentServiceError {
10+
#[error("File service error: {0}")]
11+
FileService(#[from] FileServiceError),
12+
#[error("Parse error: {0}")]
13+
Parse(String),
14+
}
15+
16+
#[derive(Clone)]
17+
pub struct DocumentService {
18+
file_service: Arc<dyn FileService>,
19+
parser: Arc<dyn MarkdownParser>,
20+
}
21+
22+
impl PartialEq for DocumentService {
23+
fn eq(&self, other: &Self) -> bool {
24+
Arc::ptr_eq(&self.file_service, &other.file_service)
25+
&& Arc::ptr_eq(&self.parser, &other.parser)
26+
}
27+
}
28+
29+
impl DocumentService {
30+
pub fn new(file_service: Arc<dyn FileService>, parser: Arc<dyn MarkdownParser>) -> Self {
31+
Self {
32+
file_service,
33+
parser,
34+
}
35+
}
36+
37+
pub fn load_document(&self, path: &Path) -> Result<Document, DocumentServiceError> {
38+
let content = self.file_service.read_file(path)?;
39+
let document = self.parser.parse(&content, path.to_path_buf());
40+
Ok(document)
41+
}
42+
43+
pub fn scan_markdown_files(&self, root: &Path) -> Result<Vec<FileEntry>, DocumentServiceError> {
44+
self.file_service
45+
.scan_markdown_files(root)
46+
.map_err(DocumentServiceError::FileService)
47+
}
48+
49+
pub fn validate_notes_structure(
50+
&self,
51+
root: &Path,
52+
) -> Result<NotesStructure, DocumentServiceError> {
53+
self.file_service
54+
.validate_notes_structure(root)
55+
.map_err(DocumentServiceError::FileService)
56+
}
57+
}
58+
59+
#[derive(Clone, PartialEq)]
60+
pub struct ApplicationServices {
61+
pub document_service: DocumentService,
62+
}
63+
64+
impl ApplicationServices {
65+
pub fn new() -> Self {
66+
let file_service = Arc::new(RealFileService::new());
67+
let parser = Arc::new(crate::domain::parsing::PulldownMarkdownParser::new());
68+
let document_service = DocumentService::new(file_service, parser);
69+
70+
Self { document_service }
71+
}
72+
73+
#[cfg(test)]
74+
pub fn with_mock_file_service(file_service: Arc<dyn FileService>) -> Self {
75+
let parser = Arc::new(crate::domain::parsing::PulldownMarkdownParser::new());
76+
let document_service = DocumentService::new(file_service, parser);
77+
78+
Self { document_service }
79+
}
80+
}
81+
82+
impl Default for ApplicationServices {
83+
fn default() -> Self {
84+
Self::new()
85+
}
86+
}
87+
88+
#[cfg(test)]
89+
mod tests {
90+
use super::*;
91+
use crate::infrastructure::MockFileService;
92+
93+
#[test]
94+
fn test_document_service_with_mock() {
95+
let mut mock_fs = MockFileService::new();
96+
mock_fs.add_file("/test.md", "- Item 1\n- Item 2");
97+
98+
let services = ApplicationServices::with_mock_file_service(Arc::new(mock_fs));
99+
let doc = services
100+
.document_service
101+
.load_document(Path::new("/test.md"))
102+
.unwrap();
103+
104+
assert_eq!(doc.outline.len(), 2);
105+
assert_eq!(doc.outline[0].content, "Item 1");
106+
assert_eq!(doc.outline[1].content, "Item 2");
107+
}
108+
109+
#[test]
110+
fn test_scan_files_with_mock() {
111+
let mut mock_fs = MockFileService::new();
112+
mock_fs.add_file("/notes/test1.md", "# Test 1");
113+
mock_fs.add_file("/notes/test2.md", "# Test 2");
114+
115+
let services = ApplicationServices::with_mock_file_service(Arc::new(mock_fs));
116+
let files = services
117+
.document_service
118+
.scan_markdown_files(Path::new("/notes"))
119+
.unwrap();
120+
121+
assert_eq!(files.len(), 2);
122+
}
123+
}

src/domain/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
pub mod models;
2+
pub mod parsing;
3+
pub mod services;
4+
5+
pub use models::*;
6+
pub use parsing::*;
7+
pub use services::*;

src/domain/models/document.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
use serde::{Deserialize, Serialize};
2+
use std::collections::HashMap;
3+
use std::path::PathBuf;
4+
5+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6+
pub struct OutlineItem {
7+
pub content: String,
8+
pub level: usize,
9+
pub children: Vec<OutlineItem>,
10+
pub metadata: HashMap<String, String>,
11+
}
12+
13+
impl OutlineItem {
14+
pub fn new(content: String, level: usize) -> Self {
15+
Self {
16+
content,
17+
level,
18+
children: Vec::new(),
19+
metadata: HashMap::new(),
20+
}
21+
}
22+
23+
pub fn with_children(content: String, level: usize, children: Vec<OutlineItem>) -> Self {
24+
Self {
25+
content,
26+
level,
27+
children,
28+
metadata: HashMap::new(),
29+
}
30+
}
31+
32+
pub fn add_child(&mut self, child: OutlineItem) {
33+
self.children.push(child);
34+
}
35+
36+
pub fn set_metadata(&mut self, key: String, value: String) {
37+
self.metadata.insert(key, value);
38+
}
39+
}
40+
41+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42+
pub struct Document {
43+
pub path: PathBuf,
44+
pub outline: Vec<OutlineItem>,
45+
pub frontmatter: HashMap<String, String>,
46+
}
47+
48+
impl Document {
49+
pub fn new(path: PathBuf) -> Self {
50+
Self {
51+
path,
52+
outline: Vec::new(),
53+
frontmatter: HashMap::new(),
54+
}
55+
}
56+
57+
pub fn with_outline(path: PathBuf, outline: Vec<OutlineItem>) -> Self {
58+
Self {
59+
path,
60+
outline,
61+
frontmatter: HashMap::new(),
62+
}
63+
}
64+
65+
pub fn add_outline_item(&mut self, item: OutlineItem) {
66+
self.outline.push(item);
67+
}
68+
}

src/domain/models/file_system.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
use std::path::PathBuf;
2+
3+
#[derive(Debug, Clone, PartialEq)]
4+
pub struct FileEntry {
5+
pub path: PathBuf,
6+
pub is_directory: bool,
7+
pub name: String,
8+
}
9+
10+
impl FileEntry {
11+
pub fn new(path: PathBuf, is_directory: bool) -> Self {
12+
let name = path
13+
.file_name()
14+
.and_then(|n| n.to_str())
15+
.unwrap_or("Unknown")
16+
.to_string();
17+
18+
Self {
19+
path,
20+
is_directory,
21+
name,
22+
}
23+
}
24+
25+
pub fn is_markdown(&self) -> bool {
26+
!self.is_directory
27+
&& self
28+
.path
29+
.extension()
30+
.and_then(|ext| ext.to_str())
31+
.map_or(false, |ext| ext == "md")
32+
}
33+
}
34+
35+
#[derive(Debug, Clone)]
36+
pub struct NotesStructure {
37+
pub root: PathBuf,
38+
pub pages_dir: PathBuf,
39+
pub journal_dir: PathBuf,
40+
pub assets_dir: PathBuf,
41+
}
42+
43+
impl NotesStructure {
44+
pub fn new(root: PathBuf) -> Self {
45+
Self {
46+
pages_dir: root.join("pages"),
47+
journal_dir: root.join("journal"),
48+
assets_dir: root.join("assets"),
49+
root,
50+
}
51+
}
52+
53+
pub fn is_valid(&self) -> bool {
54+
self.pages_dir.exists()
55+
}
56+
}

src/domain/models/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pub mod document;
2+
pub mod file_system;
3+
4+
pub use document::*;
5+
pub use file_system::*;

src/domain/parsing/markdown.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
use crate::domain::models::{Document, OutlineItem};
2+
use pulldown_cmark::{Event, Parser, Tag, TagEnd};
3+
use std::path::PathBuf;
4+
5+
pub trait MarkdownParser: Send + Sync {
6+
fn parse(&self, content: &str, path: PathBuf) -> Document;
7+
}
8+
9+
#[derive(Debug, Default)]
10+
pub struct PulldownMarkdownParser;
11+
12+
impl PulldownMarkdownParser {
13+
pub fn new() -> Self {
14+
Self::default()
15+
}
16+
}
17+
18+
impl MarkdownParser for PulldownMarkdownParser {
19+
fn parse(&self, content: &str, path: PathBuf) -> Document {
20+
let parser = Parser::new(content);
21+
let mut items: Vec<OutlineItem> = Vec::new();
22+
let mut text_stack: Vec<String> = Vec::new();
23+
let mut list_stack: Vec<usize> = Vec::new();
24+
let mut in_item = false;
25+
26+
for event in parser {
27+
match event {
28+
Event::Start(Tag::List(_)) => {
29+
list_stack.push(0);
30+
}
31+
Event::End(TagEnd::List(_)) => {
32+
list_stack.pop();
33+
}
34+
Event::Start(Tag::Item) => {
35+
text_stack.push(String::new());
36+
in_item = true;
37+
}
38+
Event::Text(text) if in_item => {
39+
if let Some(current_text) = text_stack.last_mut() {
40+
current_text.push_str(&text);
41+
}
42+
}
43+
Event::End(TagEnd::Item) => {
44+
in_item = false;
45+
if let Some(text) = text_stack.pop() {
46+
if !text.trim().is_empty() {
47+
let level = list_stack.len().saturating_sub(1);
48+
let item = OutlineItem::new(text.trim().to_string(), level);
49+
items.push(item);
50+
}
51+
}
52+
}
53+
_ => {}
54+
}
55+
}
56+
57+
let outline = super::outline::build_hierarchy(items);
58+
Document::with_outline(path, outline)
59+
}
60+
}
61+
62+
#[cfg(test)]
63+
mod tests {
64+
use super::*;
65+
use std::path::Path;
66+
67+
#[test]
68+
fn test_parse_simple_markdown() {
69+
let parser = PulldownMarkdownParser::new();
70+
let content = "- First item\n- Second item";
71+
let doc = parser.parse(content, PathBuf::from("/test.md"));
72+
73+
assert_eq!(doc.outline.len(), 2);
74+
assert_eq!(doc.outline[0].content, "First item");
75+
assert_eq!(doc.outline[1].content, "Second item");
76+
}
77+
78+
#[test]
79+
fn test_parse_nested_markdown() {
80+
let parser = PulldownMarkdownParser::new();
81+
let content = "- Parent\n - Child";
82+
let doc = parser.parse(content, PathBuf::from("/test.md"));
83+
84+
assert_eq!(doc.outline.len(), 1);
85+
assert_eq!(doc.outline[0].content, "Parent");
86+
assert_eq!(doc.outline[0].children.len(), 1);
87+
assert_eq!(doc.outline[0].children[0].content, "Child");
88+
}
89+
}

src/domain/parsing/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pub mod markdown;
2+
pub mod outline;
3+
4+
pub use markdown::*;
5+
pub use outline::*;

0 commit comments

Comments
 (0)