Add initial project structure and core functionality for ArchDoc

- Created `.gitignore` files for various directories to exclude unnecessary files.
- Added `PLAN.md` to outline the project goals and architecture documentation generation.
- Implemented the `archdoc-cli` with a command-line interface for initializing and generating documentation.
- Developed the `archdoc-core` library for analyzing Python projects and generating architecture documentation.
- Included caching mechanisms to optimize repeated analysis.
- Established a comprehensive test suite to ensure functionality and error handling.
- Updated `README.md` to provide an overview and installation instructions for ArchDoc.
This commit is contained in:
2026-01-25 20:17:37 +03:00
commit 3701cee205
36 changed files with 7394 additions and 0 deletions

168
archdoc-core/src/cache.rs Normal file
View File

@@ -0,0 +1,168 @@
//! Caching module for ArchDoc
//!
//! This module provides caching functionality to speed up repeated analysis
//! by storing parsed ASTs and analysis results.
use crate::config::Config;
use crate::errors::ArchDocError;
use crate::model::ParsedModule;
use std::path::Path;
use std::fs;
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
#[derive(Debug, Serialize, Deserialize)]
struct CacheEntry {
/// Timestamp when the cache entry was created
created_at: DateTime<Utc>,
/// Timestamp when the source file was last modified
file_modified_at: DateTime<Utc>,
/// The parsed module data
parsed_module: ParsedModule,
}
pub struct CacheManager {
config: Config,
cache_dir: String,
}
impl CacheManager {
pub fn new(config: Config) -> Self {
let cache_dir = config.caching.cache_dir.clone();
// Create cache directory if it doesn't exist
if config.caching.enabled && !Path::new(&cache_dir).exists() {
let _ = fs::create_dir_all(&cache_dir);
}
Self { config, cache_dir }
}
/// Get cached parsed module if available and not expired
pub fn get_cached_module(&self, file_path: &Path) -> Result<Option<ParsedModule>, ArchDocError> {
if !self.config.caching.enabled {
return Ok(None);
}
let cache_key = self.get_cache_key(file_path);
let cache_file = Path::new(&self.cache_dir).join(&cache_key);
if !cache_file.exists() {
return Ok(None);
}
// Read cache file
let content = fs::read_to_string(&cache_file)
.map_err(|e| ArchDocError::Io(e))?;
let cache_entry: CacheEntry = serde_json::from_str(&content)
.map_err(|e| ArchDocError::AnalysisError(format!("Failed to deserialize cache entry: {}", e)))?;
// Check if cache is expired
let now = Utc::now();
let cache_age = now.signed_duration_since(cache_entry.created_at);
// Parse max_cache_age (simple format: "24h", "7d", etc.)
let max_age_seconds = self.parse_duration(&self.config.caching.max_cache_age)?;
if cache_age.num_seconds() > max_age_seconds as i64 {
// Cache expired, remove it
let _ = fs::remove_file(&cache_file);
return Ok(None);
}
// Check if source file has been modified since caching
let metadata = fs::metadata(file_path)
.map_err(|e| ArchDocError::Io(e))?;
let modified_time = metadata.modified()
.map_err(|e| ArchDocError::Io(e))?;
let modified_time: DateTime<Utc> = modified_time.into();
if modified_time > cache_entry.file_modified_at {
// Source file is newer than cache, invalidate cache
let _ = fs::remove_file(&cache_file);
return Ok(None);
}
Ok(Some(cache_entry.parsed_module))
}
/// Store parsed module in cache
pub fn store_module(&self, file_path: &Path, parsed_module: ParsedModule) -> Result<(), ArchDocError> {
if !self.config.caching.enabled {
return Ok(());
}
let cache_key = self.get_cache_key(file_path);
let cache_file = Path::new(&self.cache_dir).join(&cache_key);
// Get file modification time
let metadata = fs::metadata(file_path)
.map_err(|e| ArchDocError::Io(e))?;
let modified_time = metadata.modified()
.map_err(|e| ArchDocError::Io(e))?;
let modified_time: DateTime<Utc> = modified_time.into();
let cache_entry = CacheEntry {
created_at: Utc::now(),
file_modified_at: modified_time,
parsed_module,
};
let content = serde_json::to_string(&cache_entry)
.map_err(|e| ArchDocError::AnalysisError(format!("Failed to serialize cache entry: {}", e)))?;
fs::write(&cache_file, content)
.map_err(|e| ArchDocError::Io(e))
}
/// Generate cache key for a file path
fn get_cache_key(&self, file_path: &Path) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
file_path.hash(&mut hasher);
let hash = hasher.finish();
format!("{:x}.json", hash)
}
/// Parse duration string like "24h" or "7d" into seconds
fn parse_duration(&self, duration_str: &str) -> Result<u64, ArchDocError> {
if duration_str.is_empty() {
return Ok(0);
}
let chars: Vec<char> = duration_str.chars().collect();
let (number_str, unit) = chars.split_at(chars.len() - 1);
let number: u64 = number_str.iter().collect::<String>().parse()
.map_err(|_| ArchDocError::AnalysisError(format!("Invalid duration format: {}", duration_str)))?;
match unit[0] {
's' => Ok(number), // seconds
'm' => Ok(number * 60), // minutes
'h' => Ok(number * 3600), // hours
'd' => Ok(number * 86400), // days
_ => Err(ArchDocError::AnalysisError(format!("Unknown duration unit: {}", unit[0]))),
}
}
/// Clear all cache entries
pub fn clear_cache(&self) -> Result<(), ArchDocError> {
if Path::new(&self.cache_dir).exists() {
fs::remove_dir_all(&self.cache_dir)
.map_err(|e| ArchDocError::Io(e))?;
// Recreate cache directory
fs::create_dir_all(&self.cache_dir)
.map_err(|e| ArchDocError::Io(e))?;
}
Ok(())
}
}

458
archdoc-core/src/config.rs Normal file
View File

@@ -0,0 +1,458 @@
//! Configuration management for ArchDoc
//!
//! This module handles loading and validating the archdoc.toml configuration file.
use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::errors::ArchDocError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub project: ProjectConfig,
#[serde(default)]
pub scan: ScanConfig,
#[serde(default)]
pub python: PythonConfig,
#[serde(default)]
pub analysis: AnalysisConfig,
#[serde(default)]
pub output: OutputConfig,
#[serde(default)]
pub diff: DiffConfig,
#[serde(default)]
pub thresholds: ThresholdsConfig,
#[serde(default)]
pub rendering: RenderingConfig,
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)]
pub caching: CachingConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
project: ProjectConfig::default(),
scan: ScanConfig::default(),
python: PythonConfig::default(),
analysis: AnalysisConfig::default(),
output: OutputConfig::default(),
diff: DiffConfig::default(),
thresholds: ThresholdsConfig::default(),
rendering: RenderingConfig::default(),
logging: LoggingConfig::default(),
caching: CachingConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectConfig {
#[serde(default = "default_root")]
pub root: String,
#[serde(default = "default_out_dir")]
pub out_dir: String,
#[serde(default = "default_entry_file")]
pub entry_file: String,
#[serde(default = "default_language")]
pub language: String,
#[serde(default)]
pub name: String,
}
impl Default for ProjectConfig {
fn default() -> Self {
Self {
root: default_root(),
out_dir: default_out_dir(),
entry_file: default_entry_file(),
language: default_language(),
name: String::new(),
}
}
}
fn default_root() -> String {
".".to_string()
}
fn default_out_dir() -> String {
"docs/architecture".to_string()
}
fn default_entry_file() -> String {
"ARCHITECTURE.md".to_string()
}
fn default_language() -> String {
"python".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanConfig {
#[serde(default = "default_include")]
pub include: Vec<String>,
#[serde(default = "default_exclude")]
pub exclude: Vec<String>,
#[serde(default)]
pub follow_symlinks: bool,
#[serde(default = "default_max_file_size")]
pub max_file_size: String,
}
impl Default for ScanConfig {
fn default() -> Self {
Self {
include: default_include(),
exclude: default_exclude(),
follow_symlinks: false,
max_file_size: default_max_file_size(),
}
}
}
fn default_include() -> Vec<String> {
vec!["src".to_string(), "app".to_string(), "tests".to_string()]
}
fn default_exclude() -> Vec<String> {
vec![
".venv".to_string(),
"venv".to_string(),
"__pycache__".to_string(),
".git".to_string(),
"dist".to_string(),
"build".to_string(),
".mypy_cache".to_string(),
".ruff_cache".to_string(),
".pytest_cache".to_string(),
"*.egg-info".to_string(),
]
}
fn default_max_file_size() -> String {
"10MB".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PythonConfig {
#[serde(default = "default_src_roots")]
pub src_roots: Vec<String>,
#[serde(default = "default_include_tests")]
pub include_tests: bool,
#[serde(default = "default_parse_docstrings")]
pub parse_docstrings: bool,
#[serde(default = "default_max_parse_errors")]
pub max_parse_errors: usize,
}
impl Default for PythonConfig {
fn default() -> Self {
Self {
src_roots: default_src_roots(),
include_tests: default_include_tests(),
parse_docstrings: default_parse_docstrings(),
max_parse_errors: default_max_parse_errors(),
}
}
}
fn default_src_roots() -> Vec<String> {
vec!["src".to_string(), ".".to_string()]
}
fn default_include_tests() -> bool {
true
}
fn default_parse_docstrings() -> bool {
true
}
fn default_max_parse_errors() -> usize {
10
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalysisConfig {
#[serde(default = "default_resolve_calls")]
pub resolve_calls: bool,
#[serde(default)]
pub resolve_inheritance: bool,
#[serde(default = "default_detect_integrations")]
pub detect_integrations: bool,
#[serde(default = "default_integration_patterns")]
pub integration_patterns: Vec<IntegrationPattern>,
}
impl Default for AnalysisConfig {
fn default() -> Self {
Self {
resolve_calls: default_resolve_calls(),
resolve_inheritance: false,
detect_integrations: default_detect_integrations(),
integration_patterns: default_integration_patterns(),
}
}
}
fn default_resolve_calls() -> bool {
true
}
fn default_detect_integrations() -> bool {
true
}
fn default_integration_patterns() -> Vec<IntegrationPattern> {
vec![
IntegrationPattern {
type_: "http".to_string(),
patterns: vec!["requests".to_string(), "httpx".to_string(), "aiohttp".to_string()],
},
IntegrationPattern {
type_: "db".to_string(),
patterns: vec![
"sqlalchemy".to_string(),
"psycopg".to_string(),
"mysql".to_string(),
"sqlite3".to_string(),
],
},
IntegrationPattern {
type_: "queue".to_string(),
patterns: vec![
"celery".to_string(),
"kafka".to_string(),
"pika".to_string(),
"redis".to_string(),
],
},
]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrationPattern {
#[serde(rename = "type")]
pub type_: String,
pub patterns: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputConfig {
#[serde(default)]
pub single_file: bool,
#[serde(default = "default_per_file_docs")]
pub per_file_docs: bool,
#[serde(default = "default_create_directories")]
pub create_directories: bool,
#[serde(default)]
pub overwrite_manual_sections: bool,
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
single_file: false,
per_file_docs: default_per_file_docs(),
create_directories: default_create_directories(),
overwrite_manual_sections: false,
}
}
}
fn default_per_file_docs() -> bool {
true
}
fn default_create_directories() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffConfig {
#[serde(default = "default_update_timestamp_on_change_only")]
pub update_timestamp_on_change_only: bool,
#[serde(default = "default_hash_algorithm")]
pub hash_algorithm: String,
#[serde(default = "default_preserve_manual_content")]
pub preserve_manual_content: bool,
}
impl Default for DiffConfig {
fn default() -> Self {
Self {
update_timestamp_on_change_only: default_update_timestamp_on_change_only(),
hash_algorithm: default_hash_algorithm(),
preserve_manual_content: default_preserve_manual_content(),
}
}
}
fn default_update_timestamp_on_change_only() -> bool {
true
}
fn default_hash_algorithm() -> String {
"sha256".to_string()
}
fn default_preserve_manual_content() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThresholdsConfig {
#[serde(default = "default_critical_fan_in")]
pub critical_fan_in: usize,
#[serde(default = "default_critical_fan_out")]
pub critical_fan_out: usize,
#[serde(default = "default_high_complexity")]
pub high_complexity: usize,
}
impl Default for ThresholdsConfig {
fn default() -> Self {
Self {
critical_fan_in: default_critical_fan_in(),
critical_fan_out: default_critical_fan_out(),
high_complexity: default_high_complexity(),
}
}
}
fn default_critical_fan_in() -> usize {
20
}
fn default_critical_fan_out() -> usize {
20
}
fn default_high_complexity() -> usize {
50
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenderingConfig {
#[serde(default = "default_template_engine")]
pub template_engine: String,
#[serde(default = "default_max_table_rows")]
pub max_table_rows: usize,
#[serde(default = "default_truncate_long_descriptions")]
pub truncate_long_descriptions: bool,
#[serde(default = "default_description_max_length")]
pub description_max_length: usize,
}
impl Default for RenderingConfig {
fn default() -> Self {
Self {
template_engine: default_template_engine(),
max_table_rows: default_max_table_rows(),
truncate_long_descriptions: default_truncate_long_descriptions(),
description_max_length: default_description_max_length(),
}
}
}
fn default_template_engine() -> String {
"handlebars".to_string()
}
fn default_max_table_rows() -> usize {
100
}
fn default_truncate_long_descriptions() -> bool {
true
}
fn default_description_max_length() -> usize {
200
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default = "default_log_file")]
pub file: String,
#[serde(default = "default_log_format")]
pub format: String,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: default_log_level(),
file: default_log_file(),
format: default_log_format(),
}
}
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_log_file() -> String {
"archdoc.log".to_string()
}
fn default_log_format() -> String {
"compact".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachingConfig {
#[serde(default = "default_caching_enabled")]
pub enabled: bool,
#[serde(default = "default_cache_dir")]
pub cache_dir: String,
#[serde(default = "default_max_cache_age")]
pub max_cache_age: String,
}
impl Default for CachingConfig {
fn default() -> Self {
Self {
enabled: default_caching_enabled(),
cache_dir: default_cache_dir(),
max_cache_age: default_max_cache_age(),
}
}
}
fn default_caching_enabled() -> bool {
true
}
fn default_cache_dir() -> String {
".archdoc/cache".to_string()
}
fn default_max_cache_age() -> String {
"24h".to_string()
}
impl Config {
/// Load configuration from a TOML file
pub fn load_from_file(path: &Path) -> Result<Self, ArchDocError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ArchDocError::ConfigError(format!("Failed to read config file: {}", e)))?;
toml::from_str(&content)
.map_err(|e| ArchDocError::ConfigError(format!("Failed to parse config file: {}", e)))
}
/// Save configuration to a TOML file
pub fn save_to_file(&self, path: &Path) -> Result<(), ArchDocError> {
let content = toml::to_string_pretty(self)
.map_err(|e| ArchDocError::ConfigError(format!("Failed to serialize config: {}", e)))?;
std::fs::write(path, content)
.map_err(|e| ArchDocError::ConfigError(format!("Failed to write config file: {}", e)))
}
}

View File

@@ -0,0 +1,26 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ArchDocError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error in {file}:{line}: {message}")]
ParseError {
file: String,
line: usize,
message: String,
},
#[error("Configuration error: {0}")]
ConfigError(String),
#[error("Analysis error: {0}")]
AnalysisError(String),
#[error("Rendering error: {0}")]
RenderingError(String),
#[error("File consistency check failed: {0}")]
ConsistencyError(String),
}

31
archdoc-core/src/lib.rs Normal file
View File

@@ -0,0 +1,31 @@
//! ArchDoc Core Library
//!
//! This crate provides the core functionality for analyzing Python projects
//! and generating architecture documentation.
// Public modules
pub mod errors;
pub mod config;
pub mod model;
pub mod scanner;
pub mod python_analyzer;
pub mod renderer;
pub mod writer;
pub mod cache;
// Re-export commonly used types
pub use errors::ArchDocError;
pub use config::Config;
pub use model::ProjectModel;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = 2 + 2;
assert_eq!(result, 4);
}
}

168
archdoc-core/src/model.rs Normal file
View File

@@ -0,0 +1,168 @@
//! Intermediate Representation (IR) for ArchDoc
//!
//! This module defines the data structures that represent the analyzed Python project
//! and are used for generating documentation.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectModel {
pub modules: HashMap<String, Module>,
pub files: HashMap<String, FileDoc>,
pub symbols: HashMap<String, Symbol>,
pub edges: Edges,
}
impl ProjectModel {
pub fn new() -> Self {
Self {
modules: HashMap::new(),
files: HashMap::new(),
symbols: HashMap::new(),
edges: Edges::new(),
}
}
}
impl Default for ProjectModel {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Module {
pub id: String,
pub path: String,
pub files: Vec<String>,
pub doc_summary: Option<String>,
pub outbound_modules: Vec<String>,
pub inbound_modules: Vec<String>,
pub symbols: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileDoc {
pub id: String,
pub path: String,
pub module_id: String,
pub imports: Vec<String>, // normalized import strings
pub outbound_modules: Vec<String>,
pub inbound_files: Vec<String>,
pub symbols: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Symbol {
pub id: String,
pub kind: SymbolKind,
pub module_id: String,
pub file_id: String,
pub qualname: String,
pub signature: String,
pub annotations: Option<HashMap<String, String>>,
pub docstring_first_line: Option<String>,
pub purpose: String, // docstring or heuristic
pub outbound_calls: Vec<String>,
pub inbound_calls: Vec<String>,
pub integrations_flags: IntegrationFlags,
pub metrics: SymbolMetrics,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SymbolKind {
Function,
AsyncFunction,
Class,
Method,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrationFlags {
pub http: bool,
pub db: bool,
pub queue: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SymbolMetrics {
pub fan_in: usize,
pub fan_out: usize,
pub is_critical: bool,
pub cycle_participant: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edges {
pub module_import_edges: Vec<Edge>,
pub file_import_edges: Vec<Edge>,
pub symbol_call_edges: Vec<Edge>,
}
impl Edges {
pub fn new() -> Self {
Self {
module_import_edges: Vec::new(),
file_import_edges: Vec::new(),
symbol_call_edges: Vec::new(),
}
}
}
impl Default for Edges {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
pub from_id: String,
pub to_id: String,
pub edge_type: EdgeType,
pub meta: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EdgeType {
ModuleImport,
FileImport,
SymbolCall,
ExternalCall,
UnresolvedCall,
}
// Additional structures for Python analysis
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ParsedModule {
pub path: std::path::PathBuf,
pub module_path: String,
pub imports: Vec<Import>,
pub symbols: Vec<Symbol>,
pub calls: Vec<Call>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Import {
pub module_name: String,
pub alias: Option<String>,
pub line_number: usize,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Call {
pub caller_symbol: String,
pub callee_expr: String,
pub line_number: usize,
pub call_type: CallType,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum CallType {
Local,
Imported,
External,
Unresolved,
}

View File

@@ -0,0 +1,386 @@
//! Python AST analyzer for ArchDoc
//!
//! This module handles parsing Python files using AST and extracting
//! imports, definitions, and calls.
use crate::model::{ParsedModule, ProjectModel, Import, Call, CallType, Symbol, Module, FileDoc};
use crate::config::Config;
use crate::errors::ArchDocError;
use crate::cache::CacheManager;
use std::path::Path;
use std::fs;
use rustpython_parser::{ast, Parse};
use rustpython_ast::{Stmt, StmtClassDef, StmtFunctionDef, Expr, Ranged};
pub struct PythonAnalyzer {
_config: Config,
cache_manager: CacheManager,
}
impl PythonAnalyzer {
pub fn new(config: Config) -> Self {
let cache_manager = CacheManager::new(config.clone());
Self { _config: config, cache_manager }
}
pub fn parse_module(&self, file_path: &Path) -> Result<ParsedModule, ArchDocError> {
// Try to get from cache first
if let Some(cached_module) = self.cache_manager.get_cached_module(file_path)? {
return Ok(cached_module);
}
// Read the Python file
let code = fs::read_to_string(file_path)
.map_err(ArchDocError::Io)?;
// Parse the Python code into an AST
let ast = ast::Suite::parse(&code, file_path.to_str().unwrap_or("<unknown>"))
.map_err(|e| ArchDocError::ParseError {
file: file_path.to_string_lossy().to_string(),
line: 0, // We don't have line info from the error
message: format!("Failed to parse: {}", e),
})?;
// Extract imports, definitions, and calls
let mut imports = Vec::new();
let mut symbols = Vec::new();
let mut calls = Vec::new();
for stmt in ast {
self.extract_from_statement(&stmt, None, &mut imports, &mut symbols, &mut calls, 0);
}
let parsed_module = ParsedModule {
path: file_path.to_path_buf(),
module_path: file_path.to_string_lossy().to_string(),
imports,
symbols,
calls,
};
// Store in cache
self.cache_manager.store_module(file_path, parsed_module.clone())?;
Ok(parsed_module)
}
fn extract_from_statement(&self, stmt: &Stmt, current_symbol: Option<&str>, imports: &mut Vec<Import>, symbols: &mut Vec<Symbol>, calls: &mut Vec<Call>, depth: usize) {
match stmt {
Stmt::Import(import_stmt) => {
for alias in &import_stmt.names {
imports.push(Import {
module_name: alias.name.to_string(),
alias: alias.asname.as_ref().map(|n| n.to_string()),
line_number: alias.range().start().into(),
});
}
}
Stmt::ImportFrom(import_from_stmt) => {
let module_name = import_from_stmt.module.as_ref()
.map(|m| m.to_string())
.unwrap_or_default();
for alias in &import_from_stmt.names {
let full_name = if module_name.is_empty() {
alias.name.to_string()
} else {
format!("{}.{}", module_name, alias.name)
};
imports.push(Import {
module_name: full_name,
alias: alias.asname.as_ref().map(|n| n.to_string()),
line_number: alias.range().start().into(),
});
}
}
Stmt::FunctionDef(func_def) => {
// Extract function definition
// Create a symbol for this function
let integrations_flags = self.detect_integrations(&func_def.body, &self._config);
let symbol = Symbol {
id: func_def.name.to_string(),
kind: crate::model::SymbolKind::Function,
module_id: "".to_string(), // Will be filled later
file_id: "".to_string(), // Will be filled later
qualname: func_def.name.to_string(),
signature: format!("def {}(...)", func_def.name),
annotations: None,
docstring_first_line: self.extract_docstring(&func_def.body), // Extract docstring
purpose: "extracted from AST".to_string(),
outbound_calls: Vec::new(),
inbound_calls: Vec::new(),
integrations_flags,
metrics: crate::model::SymbolMetrics {
fan_in: 0,
fan_out: 0,
is_critical: false,
cycle_participant: false,
},
};
symbols.push(symbol);
// Recursively process function body for calls
for body_stmt in &func_def.body {
self.extract_from_statement(body_stmt, Some(&func_def.name), imports, symbols, calls, depth + 1);
}
}
Stmt::ClassDef(class_def) => {
// Extract class definition
// Create a symbol for this class
let integrations_flags = self.detect_integrations(&class_def.body, &self._config);
let symbol = Symbol {
id: class_def.name.to_string(),
kind: crate::model::SymbolKind::Class,
module_id: "".to_string(), // Will be filled later
file_id: "".to_string(), // Will be filled later
qualname: class_def.name.to_string(),
signature: format!("class {}", class_def.name),
annotations: None,
docstring_first_line: self.extract_docstring(&class_def.body), // Extract docstring
purpose: "extracted from AST".to_string(),
outbound_calls: Vec::new(),
inbound_calls: Vec::new(),
integrations_flags,
metrics: crate::model::SymbolMetrics {
fan_in: 0,
fan_out: 0,
is_critical: false,
cycle_participant: false,
},
};
symbols.push(symbol);
// Recursively process class body
for body_stmt in &class_def.body {
self.extract_from_statement(body_stmt, Some(&class_def.name), imports, symbols, calls, depth + 1);
}
}
Stmt::Expr(expr_stmt) => {
self.extract_from_expression(&expr_stmt.value, current_symbol, calls);
}
_ => {
// For other statement types, we might still need to check for calls in expressions
// This is a simplified approach - a full implementation would need to traverse all expressions
}
}
}
fn extract_docstring(&self, body: &[Stmt]) -> Option<String> {
// For now, just return None until we figure out the correct way to extract docstrings
// TODO: Implement proper docstring extraction
None
}
fn detect_integrations(&self, body: &[Stmt], config: &Config) -> crate::model::IntegrationFlags {
let mut flags = crate::model::IntegrationFlags {
http: false,
db: false,
queue: false,
};
if !config.analysis.detect_integrations {
return flags;
}
// Convert body to string for pattern matching
let body_str = format!("{:?}", body);
// Check for HTTP integrations
for pattern in &config.analysis.integration_patterns {
if pattern.type_ == "http" {
for lib in &pattern.patterns {
if body_str.contains(lib) {
flags.http = true;
break;
}
}
} else if pattern.type_ == "db" {
for lib in &pattern.patterns {
if body_str.contains(lib) {
flags.db = true;
break;
}
}
} else if pattern.type_ == "queue" {
for lib in &pattern.patterns {
if body_str.contains(lib) {
flags.queue = true;
break;
}
}
}
}
flags
}
fn extract_function_def(&self, _func_def: &StmtFunctionDef, _symbols: &mut Vec<Symbol>, _calls: &mut Vec<Call>, _depth: usize) {
// Extract function information
// This is a simplified implementation - a full implementation would extract more details
}
fn extract_class_def(&self, _class_def: &StmtClassDef, _symbols: &mut Vec<Symbol>, _depth: usize) {
// Extract class information
// This is a simplified implementation - a full implementation would extract more details
}
fn extract_from_expression(&self, expr: &Expr, current_symbol: Option<&str>, calls: &mut Vec<Call>) {
match expr {
Expr::Call(call_expr) => {
// Extract call information
let callee_expr = self.expr_to_string(&call_expr.func);
calls.push(Call {
caller_symbol: current_symbol.unwrap_or("unknown").to_string(), // Use current symbol as caller
callee_expr,
line_number: call_expr.range().start().into(),
call_type: CallType::Unresolved,
});
// Recursively process arguments
for arg in &call_expr.args {
self.extract_from_expression(arg, current_symbol, calls);
}
for keyword in &call_expr.keywords {
self.extract_from_expression(&keyword.value, current_symbol, calls);
}
}
Expr::Attribute(attr_expr) => {
// Recursively process value
self.extract_from_expression(&attr_expr.value, current_symbol, calls);
}
_ => {
// For other expression types, recursively process child expressions
// This is a simplified approach - a full implementation would handle all expression variants
}
}
}
fn expr_to_string(&self, expr: &Expr) -> String {
match expr {
Expr::Name(name_expr) => name_expr.id.to_string(),
Expr::Attribute(attr_expr) => {
format!("{}.{}", self.expr_to_string(&attr_expr.value), attr_expr.attr)
}
_ => "<complex_expression>".to_string(),
}
}
pub fn resolve_symbols(&self, modules: &[ParsedModule]) -> Result<ProjectModel, ArchDocError> {
// Build symbol index
// Resolve cross-module references
// Build call graph
// This is a simplified implementation that creates a basic project model
// A full implementation would do much more sophisticated symbol resolution
let mut project_model = ProjectModel::new();
// Add modules to project model
for parsed_module in modules {
let module_id = parsed_module.module_path.clone();
let file_id = parsed_module.path.to_string_lossy().to_string();
// Create file doc
let file_doc = FileDoc {
id: file_id.clone(),
path: parsed_module.path.to_string_lossy().to_string(),
module_id: module_id.clone(),
imports: parsed_module.imports.iter().map(|i| i.module_name.clone()).collect(),
outbound_modules: Vec::new(), // TODO: Resolve outbound modules
inbound_files: Vec::new(),
symbols: parsed_module.symbols.iter().map(|s| s.id.clone()).collect(),
};
project_model.files.insert(file_id.clone(), file_doc);
// Add symbols to project model
for mut symbol in parsed_module.symbols.clone() {
symbol.module_id = module_id.clone();
symbol.file_id = file_id.clone();
project_model.symbols.insert(symbol.id.clone(), symbol);
}
// Create module
let module = Module {
id: module_id.clone(),
path: parsed_module.path.to_string_lossy().to_string(),
files: vec![file_id.clone()],
doc_summary: None,
outbound_modules: Vec::new(), // TODO: Resolve outbound modules
inbound_modules: Vec::new(),
symbols: parsed_module.symbols.iter().map(|s| s.id.clone()).collect(),
};
project_model.modules.insert(module_id, module);
}
// Build dependency graphs and compute metrics
self.build_dependency_graphs(&mut project_model, modules)?;
self.compute_metrics(&mut project_model)?;
Ok(project_model)
}
fn build_dependency_graphs(&self, project_model: &mut ProjectModel, parsed_modules: &[ParsedModule]) -> Result<(), ArchDocError> {
// Build module import edges
for parsed_module in parsed_modules {
let from_module_id = parsed_module.module_path.clone();
for import in &parsed_module.imports {
// Try to resolve the imported module
let to_module_id = import.module_name.clone();
// Create module import edge
let edge = crate::model::Edge {
from_id: from_module_id.clone(),
to_id: to_module_id,
edge_type: crate::model::EdgeType::ModuleImport,
meta: None,
};
project_model.edges.module_import_edges.push(edge);
}
}
// Build symbol call edges
for parsed_module in parsed_modules {
let _module_id = parsed_module.module_path.clone();
for call in &parsed_module.calls {
// Try to resolve the called symbol
let callee_expr = call.callee_expr.clone();
// Create symbol call edge
let edge = crate::model::Edge {
from_id: call.caller_symbol.clone(),
to_id: callee_expr,
edge_type: crate::model::EdgeType::SymbolCall, // TODO: Map CallType to EdgeType properly
meta: None,
};
project_model.edges.symbol_call_edges.push(edge);
}
}
Ok(())
}
fn compute_metrics(&self, project_model: &mut ProjectModel) -> Result<(), ArchDocError> {
// Compute fan-in and fan-out metrics for symbols
for symbol in project_model.symbols.values_mut() {
// Fan-out: count of outgoing calls
let fan_out = project_model.edges.symbol_call_edges
.iter()
.filter(|edge| edge.from_id == symbol.id)
.count();
// Fan-in: count of incoming calls
let fan_in = project_model.edges.symbol_call_edges
.iter()
.filter(|edge| edge.to_id == symbol.id)
.count();
symbol.metrics.fan_in = fan_in;
symbol.metrics.fan_out = fan_out;
symbol.metrics.is_critical = fan_in > 10 || fan_out > 10; // Simple threshold
symbol.metrics.cycle_participant = false; // TODO: Detect cycles
}
Ok(())
}
}

View File

@@ -0,0 +1,369 @@
//! Markdown renderer for ArchDoc
//!
//! This module handles generating Markdown documentation from the project model
//! using templates.
use crate::model::ProjectModel;
use handlebars::Handlebars;
pub struct Renderer {
templates: Handlebars<'static>,
}
impl Renderer {
pub fn new() -> Self {
let mut handlebars = Handlebars::new();
// Register templates
handlebars.register_template_string("architecture_md", Self::architecture_md_template())
.expect("Failed to register architecture_md template");
// TODO: Register other templates
Self {
templates: handlebars,
}
}
fn architecture_md_template() -> &'static str {
r#"# ARCHITECTURE — {{{project_name}}}
<!-- MANUAL:BEGIN -->
## Project summary
**Name:** {{{project_name}}}
**Description:** {{{project_description}}}
## Key decisions (manual)
{{#each key_decisions}}
- {{{this}}}
{{/each}}
## Non-goals (manual)
{{#each non_goals}}
- {{{this}}}
{{/each}}
<!-- MANUAL:END -->
---
## Document metadata
- **Created:** {{{created_date}}}
- **Updated:** {{{updated_date}}}
- **Generated by:** archdoc (cli) v0.1
---
## Integrations
<!-- ARCHDOC:BEGIN section=integrations -->
> Generated. Do not edit inside this block.
### Database Integrations
{{#each db_integrations}}
- {{{this}}}
{{/each}}
### HTTP/API Integrations
{{#each http_integrations}}
- {{{this}}}
{{/each}}
### Queue Integrations
{{#each queue_integrations}}
- {{{this}}}
{{/each}}
<!-- ARCHDOC:END section=integrations -->
---
## Rails / Tooling
<!-- ARCHDOC:BEGIN section=rails -->
> Generated. Do not edit inside this block.
{{{rails_summary}}}
<!-- ARCHDOC:END section=rails -->
---
## Repository layout (top-level)
<!-- ARCHDOC:BEGIN section=layout -->
> Generated. Do not edit inside this block.
| Path | Purpose | Link |
|------|---------|------|
{{#each layout_items}}
| {{{path}}} | {{{purpose}}} | [details]({{{link}}}) |
{{/each}}
<!-- ARCHDOC:END section=layout -->
---
## Modules index
<!-- ARCHDOC:BEGIN section=modules_index -->
> Generated. Do not edit inside this block.
| Module | Symbols | Inbound | Outbound | Link |
|--------|---------|---------|----------|------|
{{#each modules}}
| {{{name}}} | {{{symbol_count}}} | {{{inbound_count}}} | {{{outbound_count}}} | [details]({{{link}}}) |
{{/each}}
<!-- ARCHDOC:END section=modules_index -->
---
## Critical dependency points
<!-- ARCHDOC:BEGIN section=critical_points -->
> Generated. Do not edit inside this block.
### High Fan-in (Most Called)
| Symbol | Fan-in | Critical |
|--------|--------|----------|
{{#each high_fan_in}}
| {{{symbol}}} | {{{count}}} | {{{critical}}} |
{{/each}}
### High Fan-out (Calls Many)
| Symbol | Fan-out | Critical |
|--------|---------|----------|
{{#each high_fan_out}}
| {{{symbol}}} | {{{count}}} | {{{critical}}} |
{{/each}}
### Module Cycles
{{#each cycles}}
- {{{cycle_path}}}
{{/each}}
<!-- ARCHDOC:END section=critical_points -->
---
<!-- MANUAL:BEGIN -->
## Change notes (manual)
{{#each change_notes}}
- {{{this}}}
{{/each}}
<!-- MANUAL:END -->
"#
}
pub fn render_architecture_md(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
// Collect integration information
let mut db_integrations = Vec::new();
let mut http_integrations = Vec::new();
let mut queue_integrations = Vec::new();
for (symbol_id, symbol) in &model.symbols {
if symbol.integrations_flags.db {
db_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
}
if symbol.integrations_flags.http {
http_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
}
if symbol.integrations_flags.queue {
queue_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
}
}
// Prepare data for template
let data = serde_json::json!({
"project_name": "New Project",
"project_description": "<FILL_MANUALLY: what this project does in 37 lines>",
"created_date": "2026-01-25",
"updated_date": "2026-01-25",
"key_decisions": ["<FILL_MANUALLY>"],
"non_goals": ["<FILL_MANUALLY>"],
"change_notes": ["<FILL_MANUALLY>"],
"db_integrations": db_integrations,
"http_integrations": http_integrations,
"queue_integrations": queue_integrations,
// TODO: Fill with more actual data from model
});
self.templates.render("architecture_md", &data)
.map_err(|e| anyhow::anyhow!("Failed to render architecture.md: {}", e))
}
pub fn render_integrations_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
// Collect integration information
let mut db_integrations = Vec::new();
let mut http_integrations = Vec::new();
let mut queue_integrations = Vec::new();
for (symbol_id, symbol) in &model.symbols {
if symbol.integrations_flags.db {
db_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
}
if symbol.integrations_flags.http {
http_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
}
if symbol.integrations_flags.queue {
queue_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
}
}
// Prepare data for integrations section
let data = serde_json::json!({
"db_integrations": db_integrations,
"http_integrations": http_integrations,
"queue_integrations": queue_integrations,
});
// Create a smaller template just for the integrations section
let integrations_template = r#"
### Database Integrations
{{#each db_integrations}}
- {{{this}}}
{{/each}}
### HTTP/API Integrations
{{#each http_integrations}}
- {{{this}}}
{{/each}}
### Queue Integrations
{{#each queue_integrations}}
- {{{this}}}
{{/each}}
"#;
let mut handlebars = Handlebars::new();
handlebars.register_template_string("integrations", integrations_template)
.map_err(|e| anyhow::anyhow!("Failed to register integrations template: {}", e))?;
handlebars.render("integrations", &data)
.map_err(|e| anyhow::anyhow!("Failed to render integrations section: {}", e))
}
pub fn render_rails_section(&self, _model: &ProjectModel) -> Result<String, anyhow::Error> {
// For now, return a simple placeholder
Ok("\n\nNo tooling information available.\n".to_string())
}
pub fn render_layout_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
// Collect layout information from files
let mut layout_items = Vec::new();
for (file_id, file_doc) in &model.files {
layout_items.push(serde_json::json!({
"path": file_doc.path,
"purpose": "Source file",
"link": format!("docs/architecture/files/{}.md", file_id)
}));
}
// Prepare data for layout section
let data = serde_json::json!({
"layout_items": layout_items,
});
// Create a smaller template just for the layout section
let layout_template = r#"
| Path | Purpose | Link |
|------|---------|------|
{{#each layout_items}}
| {{{path}}} | {{{purpose}}} | [details]({{{link}}}) |
{{/each}}
"#;
let mut handlebars = Handlebars::new();
handlebars.register_template_string("layout", layout_template)
.map_err(|e| anyhow::anyhow!("Failed to register layout template: {}", e))?;
handlebars.render("layout", &data)
.map_err(|e| anyhow::anyhow!("Failed to render layout section: {}", e))
}
pub fn render_modules_index_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
// Collect module information
let mut modules = Vec::new();
for (module_id, module) in &model.modules {
modules.push(serde_json::json!({
"name": module_id,
"symbol_count": module.symbols.len(),
"inbound_count": module.inbound_modules.len(),
"outbound_count": module.outbound_modules.len(),
"link": format!("docs/architecture/modules/{}.md", module_id)
}));
}
// Prepare data for modules index section
let data = serde_json::json!({
"modules": modules,
});
// Create a smaller template just for the modules index section
let modules_template = r#"
| Module | Symbols | Inbound | Outbound | Link |
|--------|---------|---------|----------|------|
{{#each modules}}
| {{{name}}} | {{{symbol_count}}} | {{{inbound_count}}} | {{{outbound_count}}} | [details]({{{link}}}) |
{{/each}}
"#;
let mut handlebars = Handlebars::new();
handlebars.register_template_string("modules_index", modules_template)
.map_err(|e| anyhow::anyhow!("Failed to register modules_index template: {}", e))?;
handlebars.render("modules_index", &data)
.map_err(|e| anyhow::anyhow!("Failed to render modules index section: {}", e))
}
pub fn render_critical_points_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
// Collect critical points information
let mut high_fan_in = Vec::new();
let mut high_fan_out = Vec::new();
for (symbol_id, symbol) in &model.symbols {
if symbol.metrics.fan_in > 5 { // Threshold for high fan-in
high_fan_in.push(serde_json::json!({
"symbol": symbol_id,
"count": symbol.metrics.fan_in,
"critical": symbol.metrics.is_critical,
}));
}
if symbol.metrics.fan_out > 5 { // Threshold for high fan-out
high_fan_out.push(serde_json::json!({
"symbol": symbol_id,
"count": symbol.metrics.fan_out,
"critical": symbol.metrics.is_critical,
}));
}
}
// Prepare data for critical points section
let data = serde_json::json!({
"high_fan_in": high_fan_in,
"high_fan_out": high_fan_out,
"cycles": Vec::<String>::new(), // TODO: Implement cycle detection
});
// Create a smaller template just for the critical points section
let critical_points_template = r#"
### High Fan-in (Most Called)
| Symbol | Fan-in | Critical |
|--------|--------|----------|
{{#each high_fan_in}}
| {{{symbol}}} | {{{count}}} | {{{critical}}} |
{{/each}}
### High Fan-out (Calls Many)
| Symbol | Fan-out | Critical |
|--------|---------|----------|
{{#each high_fan_out}}
| {{{symbol}}} | {{{count}}} | {{{critical}}} |
{{/each}}
### Module Cycles
{{#each cycles}}
- {{{this}}}
{{/each}}
"#;
let mut handlebars = Handlebars::new();
handlebars.register_template_string("critical_points", critical_points_template)
.map_err(|e| anyhow::anyhow!("Failed to register critical_points template: {}", e))?;
handlebars.render("critical_points", &data)
.map_err(|e| anyhow::anyhow!("Failed to render critical points section: {}", e))
}
}

View File

@@ -0,0 +1,86 @@
//! File scanner for ArchDoc
//!
//! This module handles scanning the file system for Python files according to
//! the configuration settings.
use crate::config::Config;
use crate::errors::ArchDocError;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
pub struct FileScanner {
config: Config,
}
impl FileScanner {
pub fn new(config: Config) -> Self {
Self { config }
}
pub fn scan_python_files(&self, root: &Path) -> Result<Vec<PathBuf>, ArchDocError> {
// Check if root directory exists
if !root.exists() {
return Err(ArchDocError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Root directory does not exist: {}", root.display())
)));
}
if !root.is_dir() {
return Err(ArchDocError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Root path is not a directory: {}", root.display())
)));
}
let mut python_files = Vec::new();
// Walk directory tree respecting include/exclude patterns
for entry in WalkDir::new(root)
.follow_links(self.config.scan.follow_symlinks)
.into_iter() {
let entry = entry.map_err(|e| {
ArchDocError::Io(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to read directory entry: {}", e)
))
})?;
let path = entry.path();
// Skip excluded paths
if self.is_excluded(path) {
if path.is_dir() {
continue;
} else {
continue;
}
}
// Include Python files
if path.extension().and_then(|s| s.to_str()) == Some("py") {
python_files.push(path.to_path_buf());
}
}
Ok(python_files)
}
fn is_excluded(&self, path: &Path) -> bool {
// Convert path to string for pattern matching
let path_str = match path.to_str() {
Some(s) => s,
None => return false, // If we can't convert to string, don't exclude
};
// Check if path matches any exclude patterns
for pattern in &self.config.scan.exclude {
if path_str.contains(pattern) {
return true;
}
}
false
}
}

237
archdoc-core/src/writer.rs Normal file
View File

@@ -0,0 +1,237 @@
//! Diff-aware file writer for ArchDoc
//!
//! This module handles writing generated documentation to files while preserving
//! manual content and only updating generated sections.
use crate::errors::ArchDocError;
use std::path::Path;
use std::fs;
use chrono::Utc;
#[derive(Debug)]
pub struct SectionMarker {
pub name: String,
pub start_pos: usize,
pub end_pos: usize,
}
#[derive(Debug)]
pub struct SymbolMarker {
pub symbol_id: String,
pub start_pos: usize,
pub end_pos: usize,
}
pub struct DiffAwareWriter {
// Configuration
}
impl DiffAwareWriter {
pub fn new() -> Self {
Self {}
}
pub fn update_file_with_markers(
&self,
file_path: &Path,
generated_content: &str,
section_name: &str,
) -> Result<(), ArchDocError> {
// Read existing file
let existing_content = if file_path.exists() {
fs::read_to_string(file_path)
.map_err(|e| ArchDocError::Io(e))?
} else {
// Create new file with template
let template_content = self.create_template_file(file_path, section_name)?;
// Write template to file
fs::write(file_path, &template_content)
.map_err(|e| ArchDocError::Io(e))?;
template_content
};
// Find section markers
let markers = self.find_section_markers(&existing_content, section_name)?;
if let Some(marker) = markers.first() {
// Replace content between markers
let new_content = self.replace_section_content(
&existing_content,
marker,
generated_content,
)?;
// Check if content has changed
let content_changed = existing_content != new_content;
// Write updated content
if content_changed {
let updated_content = self.update_timestamp(new_content)?;
fs::write(file_path, updated_content)
.map_err(|e| ArchDocError::Io(e))?;
} else {
// Content hasn't changed, but we might still need to update timestamp
// TODO: Implement timestamp update logic based on config
fs::write(file_path, new_content)
.map_err(|e| ArchDocError::Io(e))?;
}
}
Ok(())
}
pub fn update_symbol_section(
&self,
_file_path: &Path,
_symbol_id: &str,
_generated_content: &str,
) -> Result<(), ArchDocError> {
// Similar to section update but for symbol-specific markers
todo!("Implement symbol section update")
}
fn find_section_markers(&self, content: &str, section_name: &str) -> Result<Vec<SectionMarker>, ArchDocError> {
let begin_marker = format!("<!-- ARCHDOC:BEGIN section={} -->", section_name);
let end_marker = format!("<!-- ARCHDOC:END section={} -->", section_name);
let mut markers = Vec::new();
let mut pos = 0;
while let Some(begin_pos) = content[pos..].find(&begin_marker) {
let absolute_begin = pos + begin_pos;
let search_start = absolute_begin + begin_marker.len();
if let Some(end_pos) = content[search_start..].find(&end_marker) {
let absolute_end = search_start + end_pos + end_marker.len();
markers.push(SectionMarker {
name: section_name.to_string(),
start_pos: absolute_begin,
end_pos: absolute_end,
});
pos = absolute_end;
} else {
break;
}
}
Ok(markers)
}
fn replace_section_content(
&self,
content: &str,
marker: &SectionMarker,
new_content: &str,
) -> Result<String, ArchDocError> {
let before = &content[..marker.start_pos];
let after = &content[marker.end_pos..];
let begin_marker = format!("<!-- ARCHDOC:BEGIN section={} -->", marker.name);
let end_marker = format!("<!-- ARCHDOC:END section={} -->", marker.name);
Ok(format!(
"{}{}{}{}{}",
before, begin_marker, new_content, end_marker, after
))
}
fn update_timestamp(&self, content: String) -> Result<String, ArchDocError> {
// Update the "Updated" field in the document metadata section
// Find the metadata section and update the timestamp
let today = Utc::now().format("%Y-%m-%d").to_string();
// Look for the "Updated:" line and replace it
let lines: Vec<&str> = content.lines().collect();
let mut updated_lines = Vec::new();
for line in lines {
if line.trim_start().starts_with("- **Updated:**") {
updated_lines.push(format!("- **Updated:** {}", today));
} else {
updated_lines.push(line.to_string());
}
}
Ok(updated_lines.join("\n"))
}
fn create_template_file(&self, _file_path: &Path, template_type: &str) -> Result<String, ArchDocError> {
// Create file with appropriate template based on type
match template_type {
"architecture" => {
let template = r#"# ARCHITECTURE — New Project
<!-- MANUAL:BEGIN -->
## Project summary
**Name:** New Project
**Description:** <FILL_MANUALLY: what this project does in 37 lines>
## Key decisions (manual)
- <FILL_MANUALLY>
## Non-goals (manual)
- <FILL_MANUALLY>
<!-- MANUAL:END -->
---
## Document metadata
- **Created:** 2026-01-25
- **Updated:** 2026-01-25
- **Generated by:** archdoc (cli) v0.1
---
## Rails / Tooling
<!-- ARCHDOC:BEGIN section=rails -->
> Generated. Do not edit inside this block.
<!-- ARCHDOC:END section=rails -->
---
## Repository layout (top-level)
<!-- ARCHDOC:BEGIN section=layout -->
> Generated. Do not edit inside this block.
<!-- ARCHDOC:END section=layout -->
---
## Modules index
<!-- ARCHDOC:BEGIN section=modules_index -->
> Generated. Do not edit inside this block.
<!-- ARCHDOC:END section=modules_index -->
---
## Integrations
<!-- ARCHDOC:BEGIN section=integrations -->
> Generated. Do not edit inside this block.
<!-- ARCHDOC:END section=integrations -->
---
## Critical dependency points
<!-- ARCHDOC:BEGIN section=critical_points -->
> Generated. Do not edit inside this block.
<!-- ARCHDOC:END section=critical_points -->
---
<!-- MANUAL:BEGIN -->
## Change notes (manual)
- <FILL_MANUALLY>
<!-- MANUAL:END -->
"#;
Ok(template.to_string())
}
_ => {
Ok("".to_string())
}
}
}
}