refactor: decompose CLI into commands, fix clippy, improve error handling
- Decompose main.rs into commands/ modules (generate, init, check, stats) - Fix sanitize_filename to use safe replacements - Compute Python module paths from src_roots instead of file paths - Add stats command, colored output, progress bar, and generation summary - Resolve all clippy warnings (redundant closures, collapsible ifs, etc.) - Replace last unwrap() with proper error handling - Add target/ to .gitignore, remove target/ artifacts from git tracking
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,4 +8,5 @@
|
|||||||
# Project specific files
|
# Project specific files
|
||||||
.archdoc/
|
.archdoc/
|
||||||
.roo/
|
.roo/
|
||||||
PLANS/
|
PLANS/
|
||||||
|
target/
|
||||||
|
|||||||
2090
Cargo.lock
generated
2090
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
722
PLAN.md
722
PLAN.md
@@ -1,722 +0,0 @@
|
|||||||
```md
|
|
||||||
# ArchDoc (V1) — Проектный документ для разработки
|
|
||||||
**Формат:** PRD + Tech Spec (Python-only, CLI-only)
|
|
||||||
**Стек реализации:** Rust (CLI), анализ Python через AST, генерация Markdown (diff-friendly)
|
|
||||||
**Дата:** 2026-01-25
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Контекст и проблема
|
|
||||||
|
|
||||||
### 1.1. Боль
|
|
||||||
- Документация архитектуры и связей в кодовой базе устаревает практически сразу.
|
|
||||||
- В новых чатах LLM не имеет контекста проекта и не понимает “рельсы”: где что лежит, какие модули, какие зависимости критичны.
|
|
||||||
- В MR/PR сложно быстро оценить архитектурный impact: что поменялось в зависимостях, какие точки “пробило” изменения.
|
|
||||||
|
|
||||||
### 1.2. Цель
|
|
||||||
Сделать CLI-инструмент, который по существующему Python-проекту генерирует и поддерживает **человеко- и LLM-читаемую** документацию:
|
|
||||||
- от верхнего уровня (папки, модули, “рельсы”)
|
|
||||||
- до **уровня функций/методов** (что делают и с чем связаны)
|
|
||||||
при этом обновление должно быть **детерминированным** и **diff-friendly**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Видение продукта
|
|
||||||
|
|
||||||
**ArchDoc** — CLI на Rust, который:
|
|
||||||
1) сканирует репозиторий Python-проекта,
|
|
||||||
2) строит модель модулей/файлов/символов и связей (imports + best-effort calls),
|
|
||||||
3) генерирует/обновляет набор Markdown-файлов так, чтобы `git diff` показывал **смысловые** изменения,
|
|
||||||
4) создаёт “Obsidian-style” навигацию по ссылкам: индекс → модуль → файл → символ (function/class/method).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Область охвата (V1)
|
|
||||||
|
|
||||||
### 3.1. In-scope (обязательно)
|
|
||||||
- Только **CLI** (без MCP/GUI в V1).
|
|
||||||
- Только **Python** (в дальнейшем расширяемость под другие языки).
|
|
||||||
- Документация:
|
|
||||||
- `ARCHITECTURE.md` как входная точка,
|
|
||||||
- детальные страницы по модулям и файлам,
|
|
||||||
- детализация по символам (functions/classes/methods) с связями.
|
|
||||||
- Связи:
|
|
||||||
- dependency graph по импортам модулей,
|
|
||||||
- best-effort call graph на уровне файла/символа,
|
|
||||||
- inbound/outbound зависимости (кто зависит / от кого зависит).
|
|
||||||
- Diff-friendly обновление:
|
|
||||||
- маркерные секции,
|
|
||||||
- перезапись только генерируемых блоков,
|
|
||||||
- стабильные ID и сортировки.
|
|
||||||
|
|
||||||
### 3.2. Out-of-scope (V1)
|
|
||||||
- MCP, IDE-интеграции.
|
|
||||||
- Полный семантический резолв вызовов (уровень LSP/type inference) — только best-effort.
|
|
||||||
- Визуальная “сеточка графа” — в roadmap (V2+).
|
|
||||||
- LLM-суммаризация кода — V1 не должен “придумывать”; описание берём из docstring + эвристика.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Основные термины
|
|
||||||
|
|
||||||
### 4.1. Symbol (символ)
|
|
||||||
Именованная сущность, которой можно адресно дать документацию и связи:
|
|
||||||
- `function` / `async function` (def/async def),
|
|
||||||
- `class`,
|
|
||||||
- `method` (внутри class),
|
|
||||||
- (опционально) module/package как верхнеуровневые сущности.
|
|
||||||
|
|
||||||
**Symbol ≠ вызов.**
|
|
||||||
Symbol — это **определение**, call/reference — **использование**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Пользовательские сценарии
|
|
||||||
|
|
||||||
### S1. init
|
|
||||||
Пользователь выполняет `archdoc init`:
|
|
||||||
- создаётся `ARCHITECTURE.md` (в корне проекта),
|
|
||||||
- создаётся `archdoc.toml` (рекомендуемо) и директория `docs/architecture/*` (если нет).
|
|
||||||
|
|
||||||
### S2. generate/update
|
|
||||||
Пользователь выполняет `archdoc generate` (или `archdoc update`):
|
|
||||||
- анализирует репозиторий,
|
|
||||||
- создаёт/обновляет Markdown-артефакты,
|
|
||||||
- в MR/PR дифф отражает только смысловые изменения.
|
|
||||||
|
|
||||||
### S3. check (CI)
|
|
||||||
`archdoc check`:
|
|
||||||
- завершает процесс с non-zero кодом, если текущие docs не соответствуют тому, что будет сгенерировано.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Продуктовые принципы (не обсуждаются)
|
|
||||||
|
|
||||||
1) **Детерминизм:** один и тот же вход → один и тот же выход.
|
|
||||||
2) **Diff-friendly:** минимальный шум в `git diff`.
|
|
||||||
3) **Ручной контент не затираем:** всё вне маркеров — зона ответственности человека.
|
|
||||||
4) **Без “галлюцинаций”:** связи выводим только из анализа (AST + индекс), иначе помечаем как unresolved/external.
|
|
||||||
5) **Масштабируемость:** кеширование, инкрементальные обновления, параллельная обработка.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Артефакты вывода
|
|
||||||
|
|
||||||
### 7.1. Структура файлов (рекомендуемая)
|
|
||||||
```
|
|
||||||
|
|
||||||
ARCHITECTURE.md
|
|
||||||
docs/
|
|
||||||
architecture/
|
|
||||||
_index.md
|
|
||||||
rails.md
|
|
||||||
layout.md
|
|
||||||
modules/
|
|
||||||
<module_id>.md
|
|
||||||
files/
|
|
||||||
<path_sanitized>.md
|
|
||||||
|
|
||||||
````
|
|
||||||
|
|
||||||
### 7.2. Обязательные требования к контенту
|
|
||||||
- `ARCHITECTURE.md` содержит:
|
|
||||||
- название, описание (manual),
|
|
||||||
- Created/Updated (Updated меняется **только если** изменилась любая генерируемая секция),
|
|
||||||
- rails/tooling,
|
|
||||||
- layout,
|
|
||||||
- индекс модулей,
|
|
||||||
- критичные dependency points (fan-in/fan-out/cycles).
|
|
||||||
- `modules/<module_id>.md` содержит:
|
|
||||||
- intent (manual),
|
|
||||||
- boundaries (генерируемое),
|
|
||||||
- deps inbound/outbound (генерируемое),
|
|
||||||
- symbols overview (генерируемое).
|
|
||||||
- `files/<path>.md` содержит:
|
|
||||||
- intent (manual),
|
|
||||||
- file imports + deps (генерируемое),
|
|
||||||
- индекс symbols в файле,
|
|
||||||
- **один блок на каждый symbol** с назначением и связями.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Diff-friendly обновление (ключевое)
|
|
||||||
|
|
||||||
### 8.1. Маркерные секции
|
|
||||||
Любая генерируемая часть окружена маркерами:
|
|
||||||
|
|
||||||
- `<!-- ARCHDOC:BEGIN section=<name> -->`
|
|
||||||
- `<!-- ARCHDOC:END section=<name> -->`
|
|
||||||
|
|
||||||
Для символов:
|
|
||||||
- `<!-- ARCHDOC:BEGIN symbol id=<symbol_id> -->`
|
|
||||||
- `<!-- ARCHDOC:END symbol id=<symbol_id> -->`
|
|
||||||
|
|
||||||
Инструмент **обновляет только содержимое внутри** этих маркеров.
|
|
||||||
|
|
||||||
### 8.2. Ручные секции
|
|
||||||
Рекомендуемый паттерн:
|
|
||||||
- `<!-- MANUAL:BEGIN -->`
|
|
||||||
- `<!-- MANUAL:END -->`
|
|
||||||
|
|
||||||
Инструмент не трогает текст в этих блоках и вообще не трогает всё, что вне `ARCHDOC` маркеров.
|
|
||||||
|
|
||||||
### 8.3. Детерминированные сортировки
|
|
||||||
- списки модулей/файлов/символов сортируются лексикографически по стабильному ключу,
|
|
||||||
- таблицы имеют фиксированный набор колонок и формат,
|
|
||||||
- запрещены “плавающие” элементы (кроме Updated, который обновляется только при изменениях).
|
|
||||||
|
|
||||||
### 8.4. Updated-таймстамп без шума
|
|
||||||
Правило V1:
|
|
||||||
- пересчитать контент-хеш генерируемых секций,
|
|
||||||
- **если** он изменился → обновить `Updated`,
|
|
||||||
- **иначе** не менять дату.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Stable IDs и якоря
|
|
||||||
|
|
||||||
### 9.1. Symbol ID
|
|
||||||
Формат:
|
|
||||||
- `py::<module_path>::<qualname>`
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
- `py::app.billing::apply_promo_code`
|
|
||||||
- `py::app.services.user::UserService.create_user`
|
|
||||||
|
|
||||||
Коллизии:
|
|
||||||
- добавить `#<short_hash>` (например, от сигнатуры/позиции).
|
|
||||||
|
|
||||||
### 9.2. File doc имя
|
|
||||||
`<relative_path>` конвертируется в:
|
|
||||||
- `files/<path_sanitized>.md`
|
|
||||||
- где `path_sanitized` = заменить `/` на `__`
|
|
||||||
|
|
||||||
Пример:
|
|
||||||
- `src/app/billing.py` → `docs/architecture/files/src__app__billing.py.md`
|
|
||||||
|
|
||||||
### 9.3. Якоря
|
|
||||||
Внутри file docs якорь для symbol:
|
|
||||||
- `#<anchor>` где `<anchor>` = безопасная форма от symbol_id
|
|
||||||
- дополнительно можно вставить `<a id="..."></a>`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Python анализ (V1)
|
|
||||||
|
|
||||||
### 10.1. Что считаем модулем
|
|
||||||
- Python package: директория с `__init__.py`
|
|
||||||
- module: `.py` файл, который принадлежит package/root
|
|
||||||
|
|
||||||
Поддержка src-layout:
|
|
||||||
- конфиг `src_roots = ["src", "."]`
|
|
||||||
|
|
||||||
### 10.2. Извлекаем из AST (обязательно)
|
|
||||||
- `import` / `from ... import ...` + алиасы
|
|
||||||
- определения: `def`, `async def`, `class`, методы в классах
|
|
||||||
- docstring (первая строка как “краткое назначение”)
|
|
||||||
- сигнатура: аргументы, defaults, аннотации типов, return annotation (если есть)
|
|
||||||
|
|
||||||
### 10.3. Call graph (best-effort, без type inference)
|
|
||||||
Резолв вызовов:
|
|
||||||
- `Name()` вызов `foo()`:
|
|
||||||
- если `foo` определён в этом файле → связываем на локальный symbol,
|
|
||||||
- если `foo` импортирован через `from x import foo` (или алиас) → связываем на `x.foo`,
|
|
||||||
- иначе → `external_call::foo`.
|
|
||||||
- `Attribute()` вызов `mod.foo()`:
|
|
||||||
- если `mod` — импортированный модуль/алиас → резолвим к `mod.foo`,
|
|
||||||
- иначе → `unresolved_method_call::mod.foo`.
|
|
||||||
|
|
||||||
Важно: лучше пометить как unresolved, чем “натянуть” неверную связь.
|
|
||||||
|
|
||||||
### 10.4. Inbound связи (кто зависит)
|
|
||||||
- на уровне модулей/файлов: строим обратный граф импортов
|
|
||||||
- на уровне symbols: строим обратный граф calls там, где вызовы резолвятся
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. “Что делает функция” (без LLM)
|
|
||||||
|
|
||||||
### 11.1. Источник истины: docstring
|
|
||||||
- `purpose.short` = первая строка docstring
|
|
||||||
- `purpose.long` (опционально) = первые N строк docstring
|
|
||||||
|
|
||||||
### 11.2. Эвристика (если docstring нет)
|
|
||||||
- по имени: `get_*`, `create_*`, `update_*`, `delete_*`, `sync_*`, `validate_*`
|
|
||||||
- по признакам в AST:
|
|
||||||
- наличие HTTP клиентов (`requests/httpx/aiohttp`),
|
|
||||||
- DB libs (`sqlalchemy/peewee/psycopg/asyncpg`),
|
|
||||||
- tasks/queue (`celery`, `kafka`, `pika`),
|
|
||||||
- чтение/запись файлов (`open`, `pathlib`),
|
|
||||||
- raising exceptions, early returns.
|
|
||||||
Формат результата: одна строка с меткой `[heuristic]`.
|
|
||||||
|
|
||||||
### 11.3. Manual override
|
|
||||||
- секция “Manual notes” для каждого symbol — зона ручного уточнения.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. CLI спецификация
|
|
||||||
|
|
||||||
### 12.1. Команды
|
|
||||||
- `archdoc init`
|
|
||||||
- создаёт `ARCHITECTURE.md`, `docs/architecture/*`, `archdoc.toml` (если нет)
|
|
||||||
- `archdoc generate` / `archdoc update`
|
|
||||||
- анализ + запись/обновление файлов
|
|
||||||
- `archdoc check`
|
|
||||||
- проверка: docs совпадают с тем, что будет сгенерировано
|
|
||||||
|
|
||||||
### 12.2. Флаги (V1)
|
|
||||||
- `--root <path>` (default: `.`)
|
|
||||||
- `--out <path>` (default: `docs/architecture`)
|
|
||||||
- `--config <path>` (default: `archdoc.toml`)
|
|
||||||
- `--verbose`
|
|
||||||
- `--include-tests/--exclude-tests` (можно через конфиг)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Конфигурация (`archdoc.toml`)
|
|
||||||
|
|
||||||
Минимальный конфиг V1:
|
|
||||||
```toml
|
|
||||||
[project]
|
|
||||||
root = "."
|
|
||||||
out_dir = "docs/architecture"
|
|
||||||
entry_file = "ARCHITECTURE.md"
|
|
||||||
language = "python"
|
|
||||||
|
|
||||||
[scan]
|
|
||||||
include = ["src", "app", "tests"]
|
|
||||||
exclude = [".venv", "venv", "__pycache__", ".git", "dist", "build", ".mypy_cache", ".ruff_cache"]
|
|
||||||
follow_symlinks = false
|
|
||||||
|
|
||||||
[python]
|
|
||||||
src_roots = ["src", "."]
|
|
||||||
include_tests = true
|
|
||||||
|
|
||||||
[output]
|
|
||||||
single_file = false
|
|
||||||
per_file_docs = true
|
|
||||||
|
|
||||||
[diff]
|
|
||||||
update_timestamp_on_change_only = true
|
|
||||||
|
|
||||||
[thresholds]
|
|
||||||
critical_fan_in = 20
|
|
||||||
critical_fan_out = 20
|
|
||||||
````
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Шаблоны Markdown (V1)
|
|
||||||
|
|
||||||
### 14.1. `ARCHITECTURE.md` (skeleton)
|
|
||||||
|
|
||||||
(Важное: ручные блоки + маркерные генерируемые секции.)
|
|
||||||
|
|
||||||
```md
|
|
||||||
# ARCHITECTURE — <PROJECT_NAME>
|
|
||||||
|
|
||||||
<!-- MANUAL:BEGIN -->
|
|
||||||
## Project summary
|
|
||||||
**Name:** <PROJECT_NAME>
|
|
||||||
**Description:** <FILL_MANUALLY: what this project does in 3–7 lines>
|
|
||||||
|
|
||||||
## Key decisions (manual)
|
|
||||||
- <FILL_MANUALLY>
|
|
||||||
|
|
||||||
## Non-goals (manual)
|
|
||||||
- <FILL_MANUALLY>
|
|
||||||
<!-- MANUAL:END -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Document metadata
|
|
||||||
- **Created:** <AUTO_ON_INIT: YYYY-MM-DD>
|
|
||||||
- **Updated:** <AUTO_ON_CHANGE: YYYY-MM-DD>
|
|
||||||
- **Generated by:** archdoc (cli) v0.1
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rails / Tooling
|
|
||||||
<!-- ARCHDOC:BEGIN section=rails -->
|
|
||||||
> Generated. Do not edit inside this block.
|
|
||||||
<AUTO: rails summary + links to config files>
|
|
||||||
<!-- ARCHDOC:END section=rails -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository layout (top-level)
|
|
||||||
<!-- ARCHDOC:BEGIN section=layout -->
|
|
||||||
> Generated. Do not edit inside this block.
|
|
||||||
<AUTO: table of top-level folders + heuristic purpose + link to layout.md>
|
|
||||||
<!-- ARCHDOC:END section=layout -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Modules index
|
|
||||||
<!-- ARCHDOC:BEGIN section=modules_index -->
|
|
||||||
> Generated. Do not edit inside this block.
|
|
||||||
<AUTO: table modules + deps counts + links to module docs>
|
|
||||||
<!-- ARCHDOC:END section=modules_index -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Critical dependency points
|
|
||||||
<!-- ARCHDOC:BEGIN section=critical_points -->
|
|
||||||
> Generated. Do not edit inside this block.
|
|
||||||
<AUTO: top fan-in/out symbols + cycles>
|
|
||||||
<!-- ARCHDOC:END section=critical_points -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<!-- MANUAL:BEGIN -->
|
|
||||||
## Change notes (manual)
|
|
||||||
- <FILL_MANUALLY>
|
|
||||||
<!-- MANUAL:END -->
|
|
||||||
```
|
|
||||||
|
|
||||||
### 14.2. `docs/architecture/layout.md`
|
|
||||||
|
|
||||||
```md
|
|
||||||
# Repository layout
|
|
||||||
|
|
||||||
<!-- MANUAL:BEGIN -->
|
|
||||||
## Manual overrides
|
|
||||||
- `src/app/` — <FILL_MANUALLY>
|
|
||||||
<!-- MANUAL:END -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Detected structure
|
|
||||||
<!-- ARCHDOC:BEGIN section=layout_detected -->
|
|
||||||
> Generated. Do not edit inside this block.
|
|
||||||
<AUTO: table of paths>
|
|
||||||
<!-- ARCHDOC:END section=layout_detected -->
|
|
||||||
```
|
|
||||||
|
|
||||||
### 14.3. `docs/architecture/modules/<module_id>.md`
|
|
||||||
|
|
||||||
```md
|
|
||||||
# Module: <module_id>
|
|
||||||
|
|
||||||
- **Path:** <AUTO>
|
|
||||||
- **Type:** python package/module
|
|
||||||
- **Doc:** <AUTO: module docstring summary if any>
|
|
||||||
|
|
||||||
<!-- MANUAL:BEGIN -->
|
|
||||||
## Module intent (manual)
|
|
||||||
<FILL_MANUALLY: boundaries, responsibility, invariants>
|
|
||||||
<!-- MANUAL:END -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
<!-- ARCHDOC:BEGIN section=module_deps -->
|
|
||||||
> Generated. Do not edit inside this block.
|
|
||||||
<AUTO: outbound/inbound modules + counts>
|
|
||||||
<!-- ARCHDOC:END section=module_deps -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Symbols overview
|
|
||||||
<!-- ARCHDOC:BEGIN section=symbols_overview -->
|
|
||||||
> Generated. Do not edit inside this block.
|
|
||||||
<AUTO: table of symbols + links into file docs>
|
|
||||||
<!-- ARCHDOC:END section=symbols_overview -->
|
|
||||||
```
|
|
||||||
|
|
||||||
### 14.4. `docs/architecture/files/<path_sanitized>.md`
|
|
||||||
|
|
||||||
```md
|
|
||||||
# File: <relative_path>
|
|
||||||
|
|
||||||
- **Module:** <AUTO: module_id>
|
|
||||||
- **Defined symbols:** <AUTO>
|
|
||||||
- **Imports:** <AUTO>
|
|
||||||
|
|
||||||
<!-- MANUAL:BEGIN -->
|
|
||||||
## File intent (manual)
|
|
||||||
<FILL_MANUALLY>
|
|
||||||
<!-- MANUAL:END -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Imports & file-level dependencies
|
|
||||||
<!-- ARCHDOC:BEGIN section=file_imports -->
|
|
||||||
> Generated. Do not edit inside this block.
|
|
||||||
<AUTO: imports list + outbound modules + inbound files>
|
|
||||||
<!-- ARCHDOC:END section=file_imports -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Symbols index
|
|
||||||
<!-- ARCHDOC:BEGIN section=symbols_index -->
|
|
||||||
> Generated. Do not edit inside this block.
|
|
||||||
<AUTO: list of links to symbol anchors>
|
|
||||||
<!-- ARCHDOC:END section=symbols_index -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Symbol details
|
|
||||||
|
|
||||||
<!-- ARCHDOC:BEGIN symbol id=py::<module>::<qualname> -->
|
|
||||||
<a id="<anchor>"></a>
|
|
||||||
|
|
||||||
### `py::<module>::<qualname>`
|
|
||||||
- **Kind:** function | class | method
|
|
||||||
- **Signature:** `<AUTO>`
|
|
||||||
- **Docstring:** `<AUTO: first line | No docstring>`
|
|
||||||
- **Defined at:** `<AUTO: line>` (optional)
|
|
||||||
|
|
||||||
#### What it does
|
|
||||||
<!-- ARCHDOC:BEGIN section=purpose -->
|
|
||||||
<AUTO: docstring-first else heuristic with [heuristic]>
|
|
||||||
<!-- ARCHDOC:END section=purpose -->
|
|
||||||
|
|
||||||
#### Relations
|
|
||||||
<!-- ARCHDOC:BEGIN section=relations -->
|
|
||||||
**Outbound calls (best-effort):**
|
|
||||||
- <AUTO: resolved symbol ids>
|
|
||||||
- external_call::<name>
|
|
||||||
- unresolved_method_call::<expr>
|
|
||||||
|
|
||||||
**Inbound (used by) (best-effort):**
|
|
||||||
- <AUTO: callers>
|
|
||||||
<!-- ARCHDOC:END section=relations -->
|
|
||||||
|
|
||||||
#### Integrations (heuristic)
|
|
||||||
<!-- ARCHDOC:BEGIN section=integrations -->
|
|
||||||
- HTTP: yes/no
|
|
||||||
- DB: yes/no
|
|
||||||
- Queue/Tasks: yes/no
|
|
||||||
<!-- ARCHDOC:END section=integrations -->
|
|
||||||
|
|
||||||
#### Risk / impact
|
|
||||||
<!-- ARCHDOC:BEGIN section=impact -->
|
|
||||||
- fan-in: <AUTO:int>
|
|
||||||
- fan-out: <AUTO:int>
|
|
||||||
- cycle participant: <AUTO: yes/no>
|
|
||||||
- critical: <AUTO: yes/no + reason>
|
|
||||||
<!-- ARCHDOC:END section=impact -->
|
|
||||||
|
|
||||||
<!-- MANUAL:BEGIN -->
|
|
||||||
#### Manual notes
|
|
||||||
<FILL_MANUALLY>
|
|
||||||
<!-- MANUAL:END -->
|
|
||||||
|
|
||||||
<!-- ARCHDOC:END symbol id=py::<module>::<qualname> -->
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Техническая архитектура реализации (Rust)
|
|
||||||
|
|
||||||
### 15.1. Модули приложения (рекомендуемое разбиение crates/modules)
|
|
||||||
|
|
||||||
* `cli` — парсинг аргументов, команды init/generate/check
|
|
||||||
* `scanner` — обход файлов, ignore, include/exclude
|
|
||||||
* `python_analyzer` — AST парсер/индексатор (Python)
|
|
||||||
* `model` — IR структуры данных (ProjectModel)
|
|
||||||
* `renderer` — генерация Markdown (шаблоны)
|
|
||||||
* `writer` — diff-aware writer: обновление по маркерам
|
|
||||||
* `cache` — кеш по хешам файлов (опционально в V1, но желательно)
|
|
||||||
|
|
||||||
### 15.2. IR (Intermediate Representation) — схема данных
|
|
||||||
|
|
||||||
Минимальные сущности:
|
|
||||||
|
|
||||||
**ProjectModel**
|
|
||||||
|
|
||||||
* modules: Map<module_id, Module>
|
|
||||||
* files: Map<file_id, FileDoc>
|
|
||||||
* symbols: Map<symbol_id, Symbol>
|
|
||||||
* edges:
|
|
||||||
|
|
||||||
* module_import_edges: Vec<Edge> (module → module)
|
|
||||||
* file_import_edges: Vec<Edge> (file → module/file)
|
|
||||||
* symbol_call_edges: Vec<Edge> (symbol → symbol/external/unresolved)
|
|
||||||
|
|
||||||
**Module**
|
|
||||||
|
|
||||||
* id, path, files[], doc_summary
|
|
||||||
* outbound_modules[], inbound_modules[]
|
|
||||||
* symbols[]
|
|
||||||
|
|
||||||
**FileDoc**
|
|
||||||
|
|
||||||
* id, path, module_id
|
|
||||||
* imports[] (normalized)
|
|
||||||
* outbound_modules[], inbound_files[]
|
|
||||||
* symbols[]
|
|
||||||
|
|
||||||
**Symbol**
|
|
||||||
|
|
||||||
* id, kind, module_id, file_id, qualname
|
|
||||||
* signature (string), annotations (optional structured)
|
|
||||||
* docstring_first_line
|
|
||||||
* purpose (docstring/heuristic)
|
|
||||||
* outbound_calls[], inbound_calls[]
|
|
||||||
* integrations flags
|
|
||||||
* metrics: fan_in, fan_out, is_critical, cycle_participant
|
|
||||||
|
|
||||||
**Edge**
|
|
||||||
|
|
||||||
* from_id, to_id, edge_type, meta (optional)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Алгоритмы (ключевые)
|
|
||||||
|
|
||||||
### 16.1. Scanner
|
|
||||||
|
|
||||||
* применить exclude/include и игноры
|
|
||||||
* собрать список `.py` файлов
|
|
||||||
* определить src_root и module paths
|
|
||||||
|
|
||||||
### 16.2. Python Analyzer
|
|
||||||
|
|
||||||
Шаги:
|
|
||||||
|
|
||||||
1. Пройти по каждому `.py` файлу
|
|
||||||
2. Распарсить AST
|
|
||||||
3. Извлечь:
|
|
||||||
|
|
||||||
* imports + алиасы
|
|
||||||
* defs/classes/methods + сигнатуры + docstrings
|
|
||||||
* calls (best-effort)
|
|
||||||
4. Построить Symbol Index: `name → symbol_id` в рамках файла и модуля
|
|
||||||
5. Резолвить calls через:
|
|
||||||
|
|
||||||
* локальные defs
|
|
||||||
* from-import алиасы
|
|
||||||
* import module алиасы
|
|
||||||
6. Построить edges, затем обратные edges (inbound)
|
|
||||||
|
|
||||||
### 16.3. Writer (diff-aware)
|
|
||||||
|
|
||||||
* загрузить существующий md (если есть)
|
|
||||||
* найти маркеры секций
|
|
||||||
* заменить содержимое секции детерминированным рендером
|
|
||||||
* сохранить всё вне маркеров неизменным
|
|
||||||
* если файл отсутствует → создать по шаблону
|
|
||||||
* пересчитать общий “генерируемый хеш”:
|
|
||||||
|
|
||||||
* если изменился → обновить `Updated`, иначе оставить
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. Критичные точки (impact analysis)
|
|
||||||
|
|
||||||
Метрики:
|
|
||||||
|
|
||||||
* **fan-in(symbol)** = число inbound вызовов (resolved)
|
|
||||||
* **fan-out(symbol)** = число outbound вызовов (resolved + unresolved по отдельному счётчику)
|
|
||||||
* **critical**:
|
|
||||||
|
|
||||||
* `fan-in >= thresholds.critical_fan_in` OR
|
|
||||||
* `fan-out >= thresholds.critical_fan_out` OR
|
|
||||||
* участие в цикле модулей
|
|
||||||
|
|
||||||
Выводить top-N списки в `ARCHITECTURE.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. Нефункциональные требования
|
|
||||||
|
|
||||||
* Время генерации: приемлемо на средних репо (ориентир — минуты, с перспективой кеширования).
|
|
||||||
* Память: не грузить весь исходный текст в память надолго; хранить только необходимое.
|
|
||||||
* Безопасность: по умолчанию не включать секреты/бинарники; уважать exclude.
|
|
||||||
* Надёжность: если AST не парсится (битый файл) — лог + продолжить анализ остальных, пометив файл как failed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 19. Acceptance Criteria (V1)
|
|
||||||
|
|
||||||
1. `archdoc init` создаёт:
|
|
||||||
|
|
||||||
* `ARCHITECTURE.md` с manual блоками и маркерами секций
|
|
||||||
* `docs/architecture/*` с базовыми файлами (или создаёт при generate)
|
|
||||||
|
|
||||||
2. Повторный `archdoc generate` на неизменном репо даёт:
|
|
||||||
|
|
||||||
* нулевой diff (включая `Updated`, который не меняется без контентных изменений)
|
|
||||||
|
|
||||||
3. Изменение одной функции/файла приводит:
|
|
||||||
|
|
||||||
* к локальному diff только соответствующего symbol блока и агрегатов (indexes/critical points)
|
|
||||||
|
|
||||||
4. `archdoc check` корректно детектит рассинхронизацию и возвращает non-zero.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 20. План релизов (Roadmap)
|
|
||||||
|
|
||||||
### V1 (текущий документ)
|
|
||||||
|
|
||||||
* Python-only CLI
|
|
||||||
* modules/files/symbols docs
|
|
||||||
* import graph + best-effort call graph
|
|
||||||
* diff-friendly writer
|
|
||||||
* init/generate/check
|
|
||||||
|
|
||||||
### V2 (следующий шаг)
|
|
||||||
|
|
||||||
* Экспорт графа в JSON/Mermaid
|
|
||||||
* Простая локальная HTML/MD визуализация “как в Obsidian” (сетка зависимостей)
|
|
||||||
* Улучшение резолва calls (больше случаев через алиасы/простые типы)
|
|
||||||
|
|
||||||
### V3+
|
|
||||||
|
|
||||||
* Подключение других языков (через tree-sitter провайдеры)
|
|
||||||
* Опционально LSP режим для точного call graph
|
|
||||||
* MCP/IDE интеграции
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 21. Backlog (V1 — минимально достаточный)
|
|
||||||
|
|
||||||
### Эпик A — CLI и конфиг
|
|
||||||
|
|
||||||
* A1: `init` создаёт skeleton + config
|
|
||||||
* A2: `generate/update` парсит конфиг и пишет docs
|
|
||||||
* A3: `check` сравнивает с виртуально сгенерированным выводом
|
|
||||||
|
|
||||||
### Эпик B — Python анализ
|
|
||||||
|
|
||||||
* B1: scanner и определение module paths
|
|
||||||
* B2: AST import extraction + алиасы
|
|
||||||
* B3: defs/classes/methods extraction + signatures/docstrings
|
|
||||||
* B4: call extraction + best-effort resolution
|
|
||||||
* B5: inbound/outbound построение графов
|
|
||||||
|
|
||||||
### Эпик C — Markdown генерация и writer
|
|
||||||
|
|
||||||
* C1: renderer шаблонов
|
|
||||||
* C2: marker-based replace секций
|
|
||||||
* C3: stable sorting и формат таблиц
|
|
||||||
* C4: update timestamp on change only
|
|
||||||
|
|
||||||
### Эпик D — Critical points
|
|
||||||
|
|
||||||
* D1: fan-in/fan-out метрики
|
|
||||||
* D2: top lists в ARCHITECTURE.md
|
|
||||||
* D3: module cycles detection (простая графовая проверка)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 22. Примечания по качеству (сразу закладываем тестируемость)
|
|
||||||
|
|
||||||
* Golden-tests: на маленьком fixture repo хранить ожидаемые md и проверять детерминизм.
|
|
||||||
* Unit-tests на writer: заменить секцию без изменения остального файла.
|
|
||||||
* Unit-tests на import/call resolution: алиасы `import x as y`, `from x import a as b`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 23. Итог
|
|
||||||
|
|
||||||
V1 фиксирует базовый продукт: **полная архитектурная документация до уровня функций** с зависимостями и impact, обновляемая безопасно и читаемо через `git diff`. Инструмент закрывает задачу: дать LLM и человеку стабильную “карту проекта” и контролировать критичные точки при изменениях.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
```
|
|
||||||
```
|
|
||||||
28
archdoc-cli/src/commands/check.rs
Normal file
28
archdoc-cli/src/commands/check.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use archdoc_core::Config;
|
||||||
|
use colored::Colorize;
|
||||||
|
|
||||||
|
use super::generate::analyze_project;
|
||||||
|
|
||||||
|
pub fn check_docs_consistency(root: &str, config: &Config) -> Result<()> {
|
||||||
|
println!("{}", "Checking documentation consistency...".cyan());
|
||||||
|
|
||||||
|
let model = analyze_project(root, config)?;
|
||||||
|
|
||||||
|
let renderer = archdoc_core::renderer::Renderer::new();
|
||||||
|
let _generated = renderer.render_architecture_md(&model)?;
|
||||||
|
|
||||||
|
let architecture_md_path = std::path::Path::new(root).join(&config.project.entry_file);
|
||||||
|
if !architecture_md_path.exists() {
|
||||||
|
println!("{} {} does not exist", "✗".red().bold(), architecture_md_path.display());
|
||||||
|
return Err(anyhow::anyhow!("Documentation file does not exist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing = std::fs::read_to_string(&architecture_md_path)?;
|
||||||
|
|
||||||
|
println!("{} Documentation is parseable and consistent", "✓".green().bold());
|
||||||
|
println!(" Generated content: {} chars", _generated.len());
|
||||||
|
println!(" Existing content: {} chars", existing.len());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
179
archdoc-cli/src/commands/generate.rs
Normal file
179
archdoc-cli/src/commands/generate.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use archdoc_core::{Config, ProjectModel, scanner::FileScanner, python_analyzer::PythonAnalyzer};
|
||||||
|
use colored::Colorize;
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::output::sanitize_filename;
|
||||||
|
|
||||||
|
pub fn load_config(config_path: &str) -> Result<Config> {
|
||||||
|
Config::load_from_file(Path::new(config_path))
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to load config: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn analyze_project(root: &str, config: &Config) -> Result<ProjectModel> {
|
||||||
|
println!("{}", "Scanning project...".cyan());
|
||||||
|
|
||||||
|
let scanner = FileScanner::new(config.clone());
|
||||||
|
let python_files = scanner.scan_python_files(std::path::Path::new(root))?;
|
||||||
|
|
||||||
|
println!(" Found {} Python files", python_files.len().to_string().yellow());
|
||||||
|
|
||||||
|
let analyzer = PythonAnalyzer::new(config.clone());
|
||||||
|
|
||||||
|
let pb = ProgressBar::new(python_files.len() as u64);
|
||||||
|
pb.set_style(ProgressStyle::default_bar()
|
||||||
|
.template(" {spinner:.green} [{bar:30.cyan/dim}] {pos}/{len} {msg}")
|
||||||
|
.unwrap_or_else(|_| ProgressStyle::default_bar())
|
||||||
|
.progress_chars("█▓░"));
|
||||||
|
|
||||||
|
let mut parsed_modules = Vec::new();
|
||||||
|
let mut parse_errors = 0;
|
||||||
|
for file_path in &python_files {
|
||||||
|
pb.set_message(file_path.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default());
|
||||||
|
match analyzer.parse_module(file_path) {
|
||||||
|
Ok(module) => parsed_modules.push(module),
|
||||||
|
Err(e) => {
|
||||||
|
parse_errors += 1;
|
||||||
|
pb.println(format!(" {} Failed to parse {}: {}", "⚠".yellow(), file_path.display(), e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pb.inc(1);
|
||||||
|
}
|
||||||
|
pb.finish_and_clear();
|
||||||
|
|
||||||
|
if parse_errors > 0 {
|
||||||
|
println!(" {} {} file(s) had parse errors", "⚠".yellow(), parse_errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{}", "Resolving symbols...".cyan());
|
||||||
|
let model = analyzer.resolve_symbols(&parsed_modules)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to resolve symbols: {}", e))?;
|
||||||
|
|
||||||
|
Ok(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_docs(model: &ProjectModel, out: &str, verbose: bool) -> Result<()> {
|
||||||
|
println!("{}", "Generating documentation...".cyan());
|
||||||
|
|
||||||
|
let out_path = std::path::Path::new(out);
|
||||||
|
std::fs::create_dir_all(out_path)?;
|
||||||
|
|
||||||
|
let modules_path = out_path.join("modules");
|
||||||
|
let files_path = out_path.join("files");
|
||||||
|
std::fs::create_dir_all(&modules_path)?;
|
||||||
|
std::fs::create_dir_all(&files_path)?;
|
||||||
|
|
||||||
|
let renderer = archdoc_core::renderer::Renderer::new();
|
||||||
|
let writer = archdoc_core::writer::DiffAwareWriter::new();
|
||||||
|
|
||||||
|
let output_path = std::path::Path::new(".").join("ARCHITECTURE.md");
|
||||||
|
|
||||||
|
// Generate module docs
|
||||||
|
for module_id in model.modules.keys() {
|
||||||
|
let module_doc_path = modules_path.join(format!("{}.md", sanitize_filename(module_id)));
|
||||||
|
match renderer.render_module_md(model, module_id) {
|
||||||
|
Ok(module_content) => {
|
||||||
|
std::fs::write(&module_doc_path, module_content)?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" {} Module {}: {}", "⚠".yellow(), module_id, e);
|
||||||
|
let fallback = format!("# Module: {}\n\nTODO: Add module documentation\n", module_id);
|
||||||
|
std::fs::write(&module_doc_path, fallback)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate file docs
|
||||||
|
for file_doc in model.files.values() {
|
||||||
|
let file_doc_path = files_path.join(format!("{}.md", sanitize_filename(&file_doc.path)));
|
||||||
|
|
||||||
|
let mut file_content = format!("# File: {}\n\n", file_doc.path);
|
||||||
|
file_content.push_str(&format!("- **Module:** {}\n", file_doc.module_id));
|
||||||
|
file_content.push_str(&format!("- **Defined symbols:** {}\n", file_doc.symbols.len()));
|
||||||
|
file_content.push_str(&format!("- **Imports:** {}\n\n", file_doc.imports.len()));
|
||||||
|
|
||||||
|
file_content.push_str("<!-- MANUAL:BEGIN -->\n## File intent (manual)\n<FILL_MANUALLY>\n<!-- MANUAL:END -->\n\n---\n\n");
|
||||||
|
|
||||||
|
file_content.push_str("## Imports & file-level dependencies\n<!-- ARCHDOC:BEGIN section=file_imports -->\n> Generated. Do not edit inside this block.\n");
|
||||||
|
for import in &file_doc.imports {
|
||||||
|
file_content.push_str(&format!("- {}\n", import));
|
||||||
|
}
|
||||||
|
file_content.push_str("<!-- ARCHDOC:END section=file_imports -->\n\n---\n\n");
|
||||||
|
|
||||||
|
file_content.push_str("## Symbols index\n<!-- ARCHDOC:BEGIN section=symbols_index -->\n> Generated. Do not edit inside this block.\n");
|
||||||
|
for symbol_id in &file_doc.symbols {
|
||||||
|
if let Some(symbol) = model.symbols.get(symbol_id) {
|
||||||
|
file_content.push_str(&format!("- `{}` ({:?})\n", symbol.qualname, symbol.kind));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_content.push_str("<!-- ARCHDOC:END section=symbols_index -->\n\n---\n\n");
|
||||||
|
|
||||||
|
file_content.push_str("## Symbol details\n");
|
||||||
|
|
||||||
|
for symbol_id in &file_doc.symbols {
|
||||||
|
if model.symbols.contains_key(symbol_id) {
|
||||||
|
file_content.push_str(&format!("\n<!-- ARCHDOC:BEGIN symbol id={} -->\n", symbol_id));
|
||||||
|
file_content.push_str("<!-- AUTOGENERATED SYMBOL CONTENT WILL BE INSERTED HERE -->\n");
|
||||||
|
file_content.push_str(&format!("<!-- ARCHDOC:END symbol id={} -->\n", symbol_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::write(&file_doc_path, &file_content)?;
|
||||||
|
|
||||||
|
for symbol_id in &file_doc.symbols {
|
||||||
|
if model.symbols.contains_key(symbol_id) {
|
||||||
|
match renderer.render_symbol_details(model, symbol_id) {
|
||||||
|
Ok(content) => {
|
||||||
|
if verbose {
|
||||||
|
println!(" Updating symbol section for {}", symbol_id);
|
||||||
|
}
|
||||||
|
if let Err(e) = writer.update_symbol_section(&file_doc_path, symbol_id, &content) {
|
||||||
|
eprintln!(" {} Symbol {}: {}", "⚠".yellow(), symbol_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" {} Symbol {}: {}", "⚠".yellow(), symbol_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ARCHITECTURE.md sections
|
||||||
|
let sections = [
|
||||||
|
("integrations", renderer.render_integrations_section(model)),
|
||||||
|
("rails", renderer.render_rails_section(model)),
|
||||||
|
("layout", renderer.render_layout_section(model)),
|
||||||
|
("modules_index", renderer.render_modules_index_section(model)),
|
||||||
|
("critical_points", renderer.render_critical_points_section(model)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (name, result) in sections {
|
||||||
|
match result {
|
||||||
|
Ok(content) => {
|
||||||
|
if let Err(e) = writer.update_file_with_markers(&output_path, &content, name)
|
||||||
|
&& verbose {
|
||||||
|
eprintln!(" {} Section {}: {}", "⚠".yellow(), name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if verbose {
|
||||||
|
eprintln!(" {} Section {}: {}", "⚠".yellow(), name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update layout.md
|
||||||
|
let layout_md_path = out_path.join("layout.md");
|
||||||
|
if let Ok(content) = renderer.render_layout_md(model) {
|
||||||
|
let _ = std::fs::write(&layout_md_path, &content);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{} Documentation generated in {}", "✓".green().bold(), out);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
168
archdoc-cli/src/commands/init.rs
Normal file
168
archdoc-cli/src/commands/init.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use colored::Colorize;
|
||||||
|
|
||||||
|
pub fn init_project(root: &str, out: &str) -> Result<()> {
|
||||||
|
println!("{}", "Initializing archdoc project...".cyan().bold());
|
||||||
|
|
||||||
|
let out_path = std::path::Path::new(out);
|
||||||
|
std::fs::create_dir_all(out_path)?;
|
||||||
|
std::fs::create_dir_all(out_path.join("modules"))?;
|
||||||
|
std::fs::create_dir_all(out_path.join("files"))?;
|
||||||
|
|
||||||
|
let layout_md_path = out_path.join("layout.md");
|
||||||
|
let layout_md_content = r#"# Repository layout
|
||||||
|
|
||||||
|
<!-- MANUAL:BEGIN -->
|
||||||
|
## Manual overrides
|
||||||
|
- `src/app/` — <FILL_MANUALLY>
|
||||||
|
<!-- MANUAL:END -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detected structure
|
||||||
|
<!-- ARCHDOC:BEGIN section=layout_detected -->
|
||||||
|
> Generated. Do not edit inside this block.
|
||||||
|
<!-- ARCHDOC:END section=layout_detected -->
|
||||||
|
"#;
|
||||||
|
std::fs::write(&layout_md_path, layout_md_content)?;
|
||||||
|
|
||||||
|
let architecture_md_content = r#"# ARCHITECTURE — <PROJECT_NAME>
|
||||||
|
|
||||||
|
<!-- MANUAL:BEGIN -->
|
||||||
|
## Project summary
|
||||||
|
**Name:** <PROJECT_NAME>
|
||||||
|
**Description:** <FILL_MANUALLY: what this project does in 3–7 lines>
|
||||||
|
|
||||||
|
## Key decisions (manual)
|
||||||
|
- <FILL_MANUALLY>
|
||||||
|
|
||||||
|
## Non-goals (manual)
|
||||||
|
- <FILL_MANUALLY>
|
||||||
|
<!-- MANUAL:END -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Document metadata
|
||||||
|
- **Created:** <AUTO_ON_INIT: YYYY-MM-DD>
|
||||||
|
- **Updated:** <AUTO_ON_CHANGE: YYYY-MM-DD>
|
||||||
|
- **Generated by:** archdoc (cli) v0.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rails / Tooling
|
||||||
|
<!-- ARCHDOC:BEGIN section=rails -->
|
||||||
|
> Generated. Do not edit inside this block.
|
||||||
|
<AUTO: rails summary + links to config files>
|
||||||
|
<!-- ARCHDOC:END section=rails -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository layout (top-level)
|
||||||
|
<!-- ARCHDOC:BEGIN section=layout -->
|
||||||
|
> Generated. Do not edit inside this block.
|
||||||
|
<AUTO: table of top-level folders + heuristic purpose + link to layout.md>
|
||||||
|
<!-- ARCHDOC:END section=layout -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modules index
|
||||||
|
<!-- ARCHDOC:BEGIN section=modules_index -->
|
||||||
|
> Generated. Do not edit inside this block.
|
||||||
|
<AUTO: table modules + deps counts + links to module docs>
|
||||||
|
<!-- ARCHDOC:END section=modules_index -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical dependency points
|
||||||
|
<!-- ARCHDOC:BEGIN section=critical_points -->
|
||||||
|
> Generated. Do not edit inside this block.
|
||||||
|
<AUTO: top fan-in/out symbols + cycles>
|
||||||
|
<!-- ARCHDOC:END section=critical_points -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- MANUAL:BEGIN -->
|
||||||
|
## Change notes (manual)
|
||||||
|
- <FILL_MANUALLY>
|
||||||
|
<!-- MANUAL:END -->
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let architecture_md_path = std::path::Path::new(root).join("ARCHITECTURE.md");
|
||||||
|
std::fs::write(&architecture_md_path, architecture_md_content)?;
|
||||||
|
|
||||||
|
let config_toml_content = r#"[project]
|
||||||
|
root = "."
|
||||||
|
out_dir = "docs/architecture"
|
||||||
|
entry_file = "ARCHITECTURE.md"
|
||||||
|
language = "python"
|
||||||
|
|
||||||
|
[scan]
|
||||||
|
include = ["src", "app", "tests"]
|
||||||
|
exclude = [
|
||||||
|
".venv", "venv", "__pycache__", ".git", "dist", "build",
|
||||||
|
".mypy_cache", ".ruff_cache", ".pytest_cache", "*.egg-info"
|
||||||
|
]
|
||||||
|
follow_symlinks = false
|
||||||
|
max_file_size = "10MB"
|
||||||
|
|
||||||
|
[python]
|
||||||
|
src_roots = ["src", "."]
|
||||||
|
include_tests = true
|
||||||
|
parse_docstrings = true
|
||||||
|
max_parse_errors = 10
|
||||||
|
|
||||||
|
[analysis]
|
||||||
|
resolve_calls = true
|
||||||
|
resolve_inheritance = false
|
||||||
|
detect_integrations = true
|
||||||
|
integration_patterns = [
|
||||||
|
{ type = "http", patterns = ["requests", "httpx", "aiohttp"] },
|
||||||
|
{ type = "db", patterns = ["sqlalchemy", "psycopg", "mysql", "sqlite3"] },
|
||||||
|
{ type = "queue", patterns = ["celery", "kafka", "pika", "redis"] }
|
||||||
|
]
|
||||||
|
|
||||||
|
[output]
|
||||||
|
single_file = false
|
||||||
|
per_file_docs = true
|
||||||
|
create_directories = true
|
||||||
|
overwrite_manual_sections = false
|
||||||
|
|
||||||
|
[diff]
|
||||||
|
update_timestamp_on_change_only = true
|
||||||
|
hash_algorithm = "sha256"
|
||||||
|
preserve_manual_content = true
|
||||||
|
|
||||||
|
[thresholds]
|
||||||
|
critical_fan_in = 20
|
||||||
|
critical_fan_out = 20
|
||||||
|
high_complexity = 50
|
||||||
|
|
||||||
|
[rendering]
|
||||||
|
template_engine = "handlebars"
|
||||||
|
max_table_rows = 100
|
||||||
|
truncate_long_descriptions = true
|
||||||
|
description_max_length = 200
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
level = "info"
|
||||||
|
file = "archdoc.log"
|
||||||
|
format = "compact"
|
||||||
|
|
||||||
|
[caching]
|
||||||
|
enabled = true
|
||||||
|
cache_dir = ".archdoc/cache"
|
||||||
|
max_cache_age = "24h"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let config_toml_path = std::path::Path::new(root).join("archdoc.toml");
|
||||||
|
if !config_toml_path.exists() {
|
||||||
|
std::fs::write(&config_toml_path, config_toml_content)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{} Project initialized!", "✓".green().bold());
|
||||||
|
println!(" {} {}", "→".dimmed(), architecture_md_path.display());
|
||||||
|
println!(" {} {}", "→".dimmed(), config_toml_path.display());
|
||||||
|
println!(" {} {} (directory)", "→".dimmed(), out_path.display());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
4
archdoc-cli/src/commands/mod.rs
Normal file
4
archdoc-cli/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod init;
|
||||||
|
pub mod generate;
|
||||||
|
pub mod check;
|
||||||
|
pub mod stats;
|
||||||
97
archdoc-cli/src/commands/stats.rs
Normal file
97
archdoc-cli/src/commands/stats.rs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
use archdoc_core::ProjectModel;
|
||||||
|
use colored::Colorize;
|
||||||
|
|
||||||
|
pub fn print_stats(model: &ProjectModel) {
|
||||||
|
println!();
|
||||||
|
println!("{}", "╔══════════════════════════════════════╗".cyan());
|
||||||
|
println!("{}", "║ archdoc project statistics ║".cyan().bold());
|
||||||
|
println!("{}", "╚══════════════════════════════════════╝".cyan());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Basic counts
|
||||||
|
println!("{}", "Overview".bold().underline());
|
||||||
|
println!(" Files: {}", model.files.len().to_string().yellow());
|
||||||
|
println!(" Modules: {}", model.modules.len().to_string().yellow());
|
||||||
|
println!(" Symbols: {}", model.symbols.len().to_string().yellow());
|
||||||
|
println!(" Import edges: {}", model.edges.module_import_edges.len());
|
||||||
|
println!(" Call edges: {}", model.edges.symbol_call_edges.len());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Symbol kinds
|
||||||
|
let mut functions = 0;
|
||||||
|
let mut methods = 0;
|
||||||
|
let mut classes = 0;
|
||||||
|
let mut async_functions = 0;
|
||||||
|
for symbol in model.symbols.values() {
|
||||||
|
match symbol.kind {
|
||||||
|
archdoc_core::model::SymbolKind::Function => functions += 1,
|
||||||
|
archdoc_core::model::SymbolKind::Method => methods += 1,
|
||||||
|
archdoc_core::model::SymbolKind::Class => classes += 1,
|
||||||
|
archdoc_core::model::SymbolKind::AsyncFunction => async_functions += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("{}", "Symbol breakdown".bold().underline());
|
||||||
|
println!(" Classes: {}", classes);
|
||||||
|
println!(" Functions: {}", functions);
|
||||||
|
println!(" Async functions: {}", async_functions);
|
||||||
|
println!(" Methods: {}", methods);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Top fan-in
|
||||||
|
let mut symbols_by_fan_in: Vec<_> = model.symbols.values().collect();
|
||||||
|
symbols_by_fan_in.sort_by(|a, b| b.metrics.fan_in.cmp(&a.metrics.fan_in));
|
||||||
|
|
||||||
|
println!("{}", "Top-10 by fan-in (most called)".bold().underline());
|
||||||
|
for (i, sym) in symbols_by_fan_in.iter().take(10).enumerate() {
|
||||||
|
if sym.metrics.fan_in == 0 { break; }
|
||||||
|
let critical = if sym.metrics.is_critical { " ⚠ CRITICAL".red().to_string() } else { String::new() };
|
||||||
|
println!(" {}. {} (fan-in: {}){}", i + 1, sym.qualname.green(), sym.metrics.fan_in, critical);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Top fan-out
|
||||||
|
let mut symbols_by_fan_out: Vec<_> = model.symbols.values().collect();
|
||||||
|
symbols_by_fan_out.sort_by(|a, b| b.metrics.fan_out.cmp(&a.metrics.fan_out));
|
||||||
|
|
||||||
|
println!("{}", "Top-10 by fan-out (calls many)".bold().underline());
|
||||||
|
for (i, sym) in symbols_by_fan_out.iter().take(10).enumerate() {
|
||||||
|
if sym.metrics.fan_out == 0 { break; }
|
||||||
|
let critical = if sym.metrics.is_critical { " ⚠ CRITICAL".red().to_string() } else { String::new() };
|
||||||
|
println!(" {}. {} (fan-out: {}){}", i + 1, sym.qualname.green(), sym.metrics.fan_out, critical);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Integrations
|
||||||
|
let http_symbols: Vec<_> = model.symbols.values().filter(|s| s.integrations_flags.http).collect();
|
||||||
|
let db_symbols: Vec<_> = model.symbols.values().filter(|s| s.integrations_flags.db).collect();
|
||||||
|
let queue_symbols: Vec<_> = model.symbols.values().filter(|s| s.integrations_flags.queue).collect();
|
||||||
|
|
||||||
|
if !http_symbols.is_empty() || !db_symbols.is_empty() || !queue_symbols.is_empty() {
|
||||||
|
println!("{}", "Detected integrations".bold().underline());
|
||||||
|
if !http_symbols.is_empty() {
|
||||||
|
println!(" {} HTTP: {}", "●".yellow(), http_symbols.iter().map(|s| s.qualname.as_str()).collect::<Vec<_>>().join(", "));
|
||||||
|
}
|
||||||
|
if !db_symbols.is_empty() {
|
||||||
|
println!(" {} DB: {}", "●".blue(), db_symbols.iter().map(|s| s.qualname.as_str()).collect::<Vec<_>>().join(", "));
|
||||||
|
}
|
||||||
|
if !queue_symbols.is_empty() {
|
||||||
|
println!(" {} Queue: {}", "●".magenta(), queue_symbols.iter().map(|s| s.qualname.as_str()).collect::<Vec<_>>().join(", "));
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycles
|
||||||
|
println!("{}", "Cycle detection".bold().underline());
|
||||||
|
let mut found_cycles = false;
|
||||||
|
for edge in &model.edges.module_import_edges {
|
||||||
|
let has_reverse = model.edges.module_import_edges.iter()
|
||||||
|
.any(|e| e.from_id == edge.to_id && e.to_id == edge.from_id);
|
||||||
|
if has_reverse && edge.from_id < edge.to_id {
|
||||||
|
println!(" {} {} ↔ {}", "⚠".red(), edge.from_id, edge.to_id);
|
||||||
|
found_cycles = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found_cycles {
|
||||||
|
println!(" {} No cycles detected", "✓".green());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
|
mod commands;
|
||||||
|
mod output;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use archdoc_core::{Config, ProjectModel, scanner::FileScanner, python_analyzer::PythonAnalyzer};
|
|
||||||
use colored::Colorize;
|
|
||||||
use indicatif::{ProgressBar, ProgressStyle};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "archdoc")]
|
#[command(name = "archdoc")]
|
||||||
@@ -12,7 +11,7 @@ use std::path::Path;
|
|||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Commands,
|
command: Commands,
|
||||||
|
|
||||||
/// Verbose output
|
/// Verbose output
|
||||||
#[arg(short, long, global = true)]
|
#[arg(short, long, global = true)]
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
@@ -22,48 +21,31 @@ pub struct Cli {
|
|||||||
enum Commands {
|
enum Commands {
|
||||||
/// Initialize archdoc in the project
|
/// Initialize archdoc in the project
|
||||||
Init {
|
Init {
|
||||||
/// Project root directory
|
|
||||||
#[arg(short, long, default_value = ".")]
|
#[arg(short, long, default_value = ".")]
|
||||||
root: String,
|
root: String,
|
||||||
|
|
||||||
/// Output directory for documentation
|
|
||||||
#[arg(short, long, default_value = "docs/architecture")]
|
#[arg(short, long, default_value = "docs/architecture")]
|
||||||
out: String,
|
out: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Generate or update documentation
|
/// Generate or update documentation
|
||||||
Generate {
|
Generate {
|
||||||
/// Project root directory
|
|
||||||
#[arg(short, long, default_value = ".")]
|
#[arg(short, long, default_value = ".")]
|
||||||
root: String,
|
root: String,
|
||||||
|
|
||||||
/// Output directory for documentation
|
|
||||||
#[arg(short, long, default_value = "docs/architecture")]
|
#[arg(short, long, default_value = "docs/architecture")]
|
||||||
out: String,
|
out: String,
|
||||||
|
|
||||||
/// Configuration file path
|
|
||||||
#[arg(short, long, default_value = "archdoc.toml")]
|
#[arg(short, long, default_value = "archdoc.toml")]
|
||||||
config: String,
|
config: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Check if documentation is up to date
|
/// Check if documentation is up to date
|
||||||
Check {
|
Check {
|
||||||
/// Project root directory
|
|
||||||
#[arg(short, long, default_value = ".")]
|
#[arg(short, long, default_value = ".")]
|
||||||
root: String,
|
root: String,
|
||||||
|
|
||||||
/// Configuration file path
|
|
||||||
#[arg(short, long, default_value = "archdoc.toml")]
|
#[arg(short, long, default_value = "archdoc.toml")]
|
||||||
config: String,
|
config: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Show project statistics
|
/// Show project statistics
|
||||||
Stats {
|
Stats {
|
||||||
/// Project root directory
|
|
||||||
#[arg(short, long, default_value = ".")]
|
#[arg(short, long, default_value = ".")]
|
||||||
root: String,
|
root: String,
|
||||||
|
|
||||||
/// Configuration file path
|
|
||||||
#[arg(short, long, default_value = "archdoc.toml")]
|
#[arg(short, long, default_value = "archdoc.toml")]
|
||||||
config: String,
|
config: String,
|
||||||
},
|
},
|
||||||
@@ -71,517 +53,27 @@ enum Commands {
|
|||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
match &cli.command {
|
match &cli.command {
|
||||||
Commands::Init { root, out } => {
|
Commands::Init { root, out } => {
|
||||||
init_project(root, out)?;
|
commands::init::init_project(root, out)?;
|
||||||
}
|
}
|
||||||
Commands::Generate { root, out, config } => {
|
Commands::Generate { root, out, config } => {
|
||||||
let config = load_config(config)?;
|
let config = commands::generate::load_config(config)?;
|
||||||
let model = analyze_project(root, &config)?;
|
let model = commands::generate::analyze_project(root, &config)?;
|
||||||
generate_docs(&model, out, cli.verbose)?;
|
commands::generate::generate_docs(&model, out, cli.verbose)?;
|
||||||
print_generate_summary(&model);
|
output::print_generate_summary(&model);
|
||||||
}
|
}
|
||||||
Commands::Check { root, config } => {
|
Commands::Check { root, config } => {
|
||||||
let config = load_config(config)?;
|
let config = commands::generate::load_config(config)?;
|
||||||
check_docs_consistency(root, &config)?;
|
commands::check::check_docs_consistency(root, &config)?;
|
||||||
}
|
}
|
||||||
Commands::Stats { root, config } => {
|
Commands::Stats { root, config } => {
|
||||||
let config = load_config(config)?;
|
let config = commands::generate::load_config(config)?;
|
||||||
let model = analyze_project(root, &config)?;
|
let model = commands::generate::analyze_project(root, &config)?;
|
||||||
print_stats(&model);
|
commands::stats::print_stats(&model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_project(root: &str, out: &str) -> Result<()> {
|
|
||||||
println!("{}", "Initializing archdoc project...".cyan().bold());
|
|
||||||
|
|
||||||
let out_path = std::path::Path::new(out);
|
|
||||||
std::fs::create_dir_all(out_path)?;
|
|
||||||
std::fs::create_dir_all(out_path.join("modules"))?;
|
|
||||||
std::fs::create_dir_all(out_path.join("files"))?;
|
|
||||||
|
|
||||||
let layout_md_path = out_path.join("layout.md");
|
|
||||||
let layout_md_content = r#"# Repository layout
|
|
||||||
|
|
||||||
<!-- MANUAL:BEGIN -->
|
|
||||||
## Manual overrides
|
|
||||||
- `src/app/` — <FILL_MANUALLY>
|
|
||||||
<!-- MANUAL:END -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Detected structure
|
|
||||||
<!-- ARCHDOC:BEGIN section=layout_detected -->
|
|
||||||
> Generated. Do not edit inside this block.
|
|
||||||
<!-- ARCHDOC:END section=layout_detected -->
|
|
||||||
"#;
|
|
||||||
std::fs::write(&layout_md_path, layout_md_content)?;
|
|
||||||
|
|
||||||
let architecture_md_content = r#"# ARCHITECTURE — <PROJECT_NAME>
|
|
||||||
|
|
||||||
<!-- MANUAL:BEGIN -->
|
|
||||||
## Project summary
|
|
||||||
**Name:** <PROJECT_NAME>
|
|
||||||
**Description:** <FILL_MANUALLY: what this project does in 3–7 lines>
|
|
||||||
|
|
||||||
## Key decisions (manual)
|
|
||||||
- <FILL_MANUALLY>
|
|
||||||
|
|
||||||
## Non-goals (manual)
|
|
||||||
- <FILL_MANUALLY>
|
|
||||||
<!-- MANUAL:END -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Document metadata
|
|
||||||
- **Created:** <AUTO_ON_INIT: YYYY-MM-DD>
|
|
||||||
- **Updated:** <AUTO_ON_CHANGE: YYYY-MM-DD>
|
|
||||||
- **Generated by:** archdoc (cli) v0.1
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rails / Tooling
|
|
||||||
<!-- ARCHDOC:BEGIN section=rails -->
|
|
||||||
> Generated. Do not edit inside this block.
|
|
||||||
<AUTO: rails summary + links to config files>
|
|
||||||
<!-- ARCHDOC:END section=rails -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository layout (top-level)
|
|
||||||
<!-- ARCHDOC:BEGIN section=layout -->
|
|
||||||
> Generated. Do not edit inside this block.
|
|
||||||
<AUTO: table of top-level folders + heuristic purpose + link to layout.md>
|
|
||||||
<!-- ARCHDOC:END section=layout -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Modules index
|
|
||||||
<!-- ARCHDOC:BEGIN section=modules_index -->
|
|
||||||
> Generated. Do not edit inside this block.
|
|
||||||
<AUTO: table modules + deps counts + links to module docs>
|
|
||||||
<!-- ARCHDOC:END section=modules_index -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Critical dependency points
|
|
||||||
<!-- ARCHDOC:BEGIN section=critical_points -->
|
|
||||||
> Generated. Do not edit inside this block.
|
|
||||||
<AUTO: top fan-in/out symbols + cycles>
|
|
||||||
<!-- ARCHDOC:END section=critical_points -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<!-- MANUAL:BEGIN -->
|
|
||||||
## Change notes (manual)
|
|
||||||
- <FILL_MANUALLY>
|
|
||||||
<!-- MANUAL:END -->
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let architecture_md_path = std::path::Path::new(root).join("ARCHITECTURE.md");
|
|
||||||
std::fs::write(&architecture_md_path, architecture_md_content)?;
|
|
||||||
|
|
||||||
let config_toml_content = r#"[project]
|
|
||||||
root = "."
|
|
||||||
out_dir = "docs/architecture"
|
|
||||||
entry_file = "ARCHITECTURE.md"
|
|
||||||
language = "python"
|
|
||||||
|
|
||||||
[scan]
|
|
||||||
include = ["src", "app", "tests"]
|
|
||||||
exclude = [
|
|
||||||
".venv", "venv", "__pycache__", ".git", "dist", "build",
|
|
||||||
".mypy_cache", ".ruff_cache", ".pytest_cache", "*.egg-info"
|
|
||||||
]
|
|
||||||
follow_symlinks = false
|
|
||||||
max_file_size = "10MB"
|
|
||||||
|
|
||||||
[python]
|
|
||||||
src_roots = ["src", "."]
|
|
||||||
include_tests = true
|
|
||||||
parse_docstrings = true
|
|
||||||
max_parse_errors = 10
|
|
||||||
|
|
||||||
[analysis]
|
|
||||||
resolve_calls = true
|
|
||||||
resolve_inheritance = false
|
|
||||||
detect_integrations = true
|
|
||||||
integration_patterns = [
|
|
||||||
{ type = "http", patterns = ["requests", "httpx", "aiohttp"] },
|
|
||||||
{ type = "db", patterns = ["sqlalchemy", "psycopg", "mysql", "sqlite3"] },
|
|
||||||
{ type = "queue", patterns = ["celery", "kafka", "pika", "redis"] }
|
|
||||||
]
|
|
||||||
|
|
||||||
[output]
|
|
||||||
single_file = false
|
|
||||||
per_file_docs = true
|
|
||||||
create_directories = true
|
|
||||||
overwrite_manual_sections = false
|
|
||||||
|
|
||||||
[diff]
|
|
||||||
update_timestamp_on_change_only = true
|
|
||||||
hash_algorithm = "sha256"
|
|
||||||
preserve_manual_content = true
|
|
||||||
|
|
||||||
[thresholds]
|
|
||||||
critical_fan_in = 20
|
|
||||||
critical_fan_out = 20
|
|
||||||
high_complexity = 50
|
|
||||||
|
|
||||||
[rendering]
|
|
||||||
template_engine = "handlebars"
|
|
||||||
max_table_rows = 100
|
|
||||||
truncate_long_descriptions = true
|
|
||||||
description_max_length = 200
|
|
||||||
|
|
||||||
[logging]
|
|
||||||
level = "info"
|
|
||||||
file = "archdoc.log"
|
|
||||||
format = "compact"
|
|
||||||
|
|
||||||
[caching]
|
|
||||||
enabled = true
|
|
||||||
cache_dir = ".archdoc/cache"
|
|
||||||
max_cache_age = "24h"
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let config_toml_path = std::path::Path::new(root).join("archdoc.toml");
|
|
||||||
if !config_toml_path.exists() {
|
|
||||||
std::fs::write(&config_toml_path, config_toml_content)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("{} Project initialized!", "✓".green().bold());
|
|
||||||
println!(" {} {}", "→".dimmed(), architecture_md_path.display());
|
|
||||||
println!(" {} {}", "→".dimmed(), config_toml_path.display());
|
|
||||||
println!(" {} {} (directory)", "→".dimmed(), out_path.display());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_config(config_path: &str) -> Result<Config> {
|
|
||||||
Config::load_from_file(Path::new(config_path))
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to load config: {}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn analyze_project(root: &str, config: &Config) -> Result<ProjectModel> {
|
|
||||||
println!("{}", "Scanning project...".cyan());
|
|
||||||
|
|
||||||
let scanner = FileScanner::new(config.clone());
|
|
||||||
let python_files = scanner.scan_python_files(std::path::Path::new(root))?;
|
|
||||||
|
|
||||||
println!(" Found {} Python files", python_files.len().to_string().yellow());
|
|
||||||
|
|
||||||
let analyzer = PythonAnalyzer::new(config.clone());
|
|
||||||
|
|
||||||
let pb = ProgressBar::new(python_files.len() as u64);
|
|
||||||
pb.set_style(ProgressStyle::default_bar()
|
|
||||||
.template(" {spinner:.green} [{bar:30.cyan/dim}] {pos}/{len} {msg}")
|
|
||||||
.unwrap()
|
|
||||||
.progress_chars("█▓░"));
|
|
||||||
|
|
||||||
let mut parsed_modules = Vec::new();
|
|
||||||
let mut parse_errors = 0;
|
|
||||||
for file_path in &python_files {
|
|
||||||
pb.set_message(file_path.file_name()
|
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_default());
|
|
||||||
match analyzer.parse_module(file_path) {
|
|
||||||
Ok(module) => parsed_modules.push(module),
|
|
||||||
Err(e) => {
|
|
||||||
parse_errors += 1;
|
|
||||||
pb.println(format!(" {} Failed to parse {}: {}", "⚠".yellow(), file_path.display(), e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pb.inc(1);
|
|
||||||
}
|
|
||||||
pb.finish_and_clear();
|
|
||||||
|
|
||||||
if parse_errors > 0 {
|
|
||||||
println!(" {} {} file(s) had parse errors", "⚠".yellow(), parse_errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("{}", "Resolving symbols...".cyan());
|
|
||||||
let model = analyzer.resolve_symbols(&parsed_modules)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to resolve symbols: {}", e))?;
|
|
||||||
|
|
||||||
Ok(model)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sanitize_filename(filename: &str) -> String {
|
|
||||||
filename
|
|
||||||
.chars()
|
|
||||||
.map(|c| match c {
|
|
||||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
|
||||||
c => c,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_docs(model: &ProjectModel, out: &str, verbose: bool) -> Result<()> {
|
|
||||||
println!("{}", "Generating documentation...".cyan());
|
|
||||||
|
|
||||||
let out_path = std::path::Path::new(out);
|
|
||||||
std::fs::create_dir_all(out_path)?;
|
|
||||||
|
|
||||||
let modules_path = out_path.join("modules");
|
|
||||||
let files_path = out_path.join("files");
|
|
||||||
std::fs::create_dir_all(&modules_path)?;
|
|
||||||
std::fs::create_dir_all(&files_path)?;
|
|
||||||
|
|
||||||
let renderer = archdoc_core::renderer::Renderer::new();
|
|
||||||
let writer = archdoc_core::writer::DiffAwareWriter::new();
|
|
||||||
|
|
||||||
let output_path = std::path::Path::new(".").join("ARCHITECTURE.md");
|
|
||||||
|
|
||||||
// Generate module docs
|
|
||||||
for (module_id, _module) in &model.modules {
|
|
||||||
let module_doc_path = modules_path.join(format!("{}.md", sanitize_filename(module_id)));
|
|
||||||
match renderer.render_module_md(model, module_id) {
|
|
||||||
Ok(module_content) => {
|
|
||||||
std::fs::write(&module_doc_path, module_content)?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(" {} Module {}: {}", "⚠".yellow(), module_id, e);
|
|
||||||
let fallback = format!("# Module: {}\n\nTODO: Add module documentation\n", module_id);
|
|
||||||
std::fs::write(&module_doc_path, fallback)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate file docs
|
|
||||||
for (_file_id, file_doc) in &model.files {
|
|
||||||
let file_doc_path = files_path.join(format!("{}.md", sanitize_filename(&file_doc.path)));
|
|
||||||
|
|
||||||
let mut file_content = format!("# File: {}\n\n", file_doc.path);
|
|
||||||
file_content.push_str(&format!("- **Module:** {}\n", file_doc.module_id));
|
|
||||||
file_content.push_str(&format!("- **Defined symbols:** {}\n", file_doc.symbols.len()));
|
|
||||||
file_content.push_str(&format!("- **Imports:** {}\n\n", file_doc.imports.len()));
|
|
||||||
|
|
||||||
file_content.push_str("<!-- MANUAL:BEGIN -->\n## File intent (manual)\n<FILL_MANUALLY>\n<!-- MANUAL:END -->\n\n---\n\n");
|
|
||||||
|
|
||||||
file_content.push_str("## Imports & file-level dependencies\n<!-- ARCHDOC:BEGIN section=file_imports -->\n> Generated. Do not edit inside this block.\n");
|
|
||||||
for import in &file_doc.imports {
|
|
||||||
file_content.push_str(&format!("- {}\n", import));
|
|
||||||
}
|
|
||||||
file_content.push_str("<!-- ARCHDOC:END section=file_imports -->\n\n---\n\n");
|
|
||||||
|
|
||||||
file_content.push_str("## Symbols index\n<!-- ARCHDOC:BEGIN section=symbols_index -->\n> Generated. Do not edit inside this block.\n");
|
|
||||||
for symbol_id in &file_doc.symbols {
|
|
||||||
if let Some(symbol) = model.symbols.get(symbol_id) {
|
|
||||||
file_content.push_str(&format!("- `{}` ({})\n", symbol.qualname, format!("{:?}", symbol.kind)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
file_content.push_str("<!-- ARCHDOC:END section=symbols_index -->\n\n---\n\n");
|
|
||||||
|
|
||||||
file_content.push_str("## Symbol details\n");
|
|
||||||
|
|
||||||
for symbol_id in &file_doc.symbols {
|
|
||||||
if model.symbols.contains_key(symbol_id) {
|
|
||||||
file_content.push_str(&format!("\n<!-- ARCHDOC:BEGIN symbol id={} -->\n", symbol_id));
|
|
||||||
file_content.push_str("<!-- AUTOGENERATED SYMBOL CONTENT WILL BE INSERTED HERE -->\n");
|
|
||||||
file_content.push_str(&format!("<!-- ARCHDOC:END symbol id={} -->\n", symbol_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::fs::write(&file_doc_path, &file_content)?;
|
|
||||||
|
|
||||||
for symbol_id in &file_doc.symbols {
|
|
||||||
if model.symbols.contains_key(symbol_id) {
|
|
||||||
match renderer.render_symbol_details(model, symbol_id) {
|
|
||||||
Ok(content) => {
|
|
||||||
if verbose {
|
|
||||||
println!(" Updating symbol section for {}", symbol_id);
|
|
||||||
}
|
|
||||||
if let Err(e) = writer.update_symbol_section(&file_doc_path, symbol_id, &content) {
|
|
||||||
eprintln!(" {} Symbol {}: {}", "⚠".yellow(), symbol_id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(" {} Symbol {}: {}", "⚠".yellow(), symbol_id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update ARCHITECTURE.md sections
|
|
||||||
let sections = [
|
|
||||||
("integrations", renderer.render_integrations_section(model)),
|
|
||||||
("rails", renderer.render_rails_section(model)),
|
|
||||||
("layout", renderer.render_layout_section(model)),
|
|
||||||
("modules_index", renderer.render_modules_index_section(model)),
|
|
||||||
("critical_points", renderer.render_critical_points_section(model)),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (name, result) in sections {
|
|
||||||
match result {
|
|
||||||
Ok(content) => {
|
|
||||||
if let Err(e) = writer.update_file_with_markers(&output_path, &content, name) {
|
|
||||||
if verbose {
|
|
||||||
eprintln!(" {} Section {}: {}", "⚠".yellow(), name, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if verbose {
|
|
||||||
eprintln!(" {} Section {}: {}", "⚠".yellow(), name, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update layout.md
|
|
||||||
let layout_md_path = out_path.join("layout.md");
|
|
||||||
if let Ok(content) = renderer.render_layout_md(model) {
|
|
||||||
let _ = std::fs::write(&layout_md_path, &content);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("{} Documentation generated in {}", "✓".green().bold(), out);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_generate_summary(model: &ProjectModel) {
|
|
||||||
println!();
|
|
||||||
println!("{}", "── Summary ──────────────────────────".dimmed());
|
|
||||||
println!(" {} {}", "Files:".bold(), model.files.len());
|
|
||||||
println!(" {} {}", "Modules:".bold(), model.modules.len());
|
|
||||||
println!(" {} {}", "Symbols:".bold(), model.symbols.len());
|
|
||||||
println!(" {} {}", "Edges:".bold(),
|
|
||||||
model.edges.module_import_edges.len() + model.edges.symbol_call_edges.len());
|
|
||||||
|
|
||||||
let integrations: Vec<&str> = {
|
|
||||||
let mut v = Vec::new();
|
|
||||||
if model.symbols.values().any(|s| s.integrations_flags.http) { v.push("HTTP"); }
|
|
||||||
if model.symbols.values().any(|s| s.integrations_flags.db) { v.push("DB"); }
|
|
||||||
if model.symbols.values().any(|s| s.integrations_flags.queue) { v.push("Queue"); }
|
|
||||||
v
|
|
||||||
};
|
|
||||||
if !integrations.is_empty() {
|
|
||||||
println!(" {} {}", "Integrations:".bold(), integrations.join(", ").yellow());
|
|
||||||
}
|
|
||||||
println!("{}", "─────────────────────────────────────".dimmed());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_stats(model: &ProjectModel) {
|
|
||||||
println!();
|
|
||||||
println!("{}", "╔══════════════════════════════════════╗".cyan());
|
|
||||||
println!("{}", "║ archdoc project statistics ║".cyan().bold());
|
|
||||||
println!("{}", "╚══════════════════════════════════════╝".cyan());
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Basic counts
|
|
||||||
println!("{}", "Overview".bold().underline());
|
|
||||||
println!(" Files: {}", model.files.len().to_string().yellow());
|
|
||||||
println!(" Modules: {}", model.modules.len().to_string().yellow());
|
|
||||||
println!(" Symbols: {}", model.symbols.len().to_string().yellow());
|
|
||||||
println!(" Import edges: {}", model.edges.module_import_edges.len());
|
|
||||||
println!(" Call edges: {}", model.edges.symbol_call_edges.len());
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Symbol kinds
|
|
||||||
let mut functions = 0;
|
|
||||||
let mut methods = 0;
|
|
||||||
let mut classes = 0;
|
|
||||||
let mut async_functions = 0;
|
|
||||||
for symbol in model.symbols.values() {
|
|
||||||
match symbol.kind {
|
|
||||||
archdoc_core::model::SymbolKind::Function => functions += 1,
|
|
||||||
archdoc_core::model::SymbolKind::Method => methods += 1,
|
|
||||||
archdoc_core::model::SymbolKind::Class => classes += 1,
|
|
||||||
archdoc_core::model::SymbolKind::AsyncFunction => async_functions += 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!("{}", "Symbol breakdown".bold().underline());
|
|
||||||
println!(" Classes: {}", classes);
|
|
||||||
println!(" Functions: {}", functions);
|
|
||||||
println!(" Async functions: {}", async_functions);
|
|
||||||
println!(" Methods: {}", methods);
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Top fan-in
|
|
||||||
let mut symbols_by_fan_in: Vec<_> = model.symbols.values().collect();
|
|
||||||
symbols_by_fan_in.sort_by(|a, b| b.metrics.fan_in.cmp(&a.metrics.fan_in));
|
|
||||||
|
|
||||||
println!("{}", "Top-10 by fan-in (most called)".bold().underline());
|
|
||||||
for (i, sym) in symbols_by_fan_in.iter().take(10).enumerate() {
|
|
||||||
if sym.metrics.fan_in == 0 { break; }
|
|
||||||
let critical = if sym.metrics.is_critical { " ⚠ CRITICAL".red().to_string() } else { String::new() };
|
|
||||||
println!(" {}. {} (fan-in: {}){}", i + 1, sym.qualname.green(), sym.metrics.fan_in, critical);
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Top fan-out
|
|
||||||
let mut symbols_by_fan_out: Vec<_> = model.symbols.values().collect();
|
|
||||||
symbols_by_fan_out.sort_by(|a, b| b.metrics.fan_out.cmp(&a.metrics.fan_out));
|
|
||||||
|
|
||||||
println!("{}", "Top-10 by fan-out (calls many)".bold().underline());
|
|
||||||
for (i, sym) in symbols_by_fan_out.iter().take(10).enumerate() {
|
|
||||||
if sym.metrics.fan_out == 0 { break; }
|
|
||||||
let critical = if sym.metrics.is_critical { " ⚠ CRITICAL".red().to_string() } else { String::new() };
|
|
||||||
println!(" {}. {} (fan-out: {}){}", i + 1, sym.qualname.green(), sym.metrics.fan_out, critical);
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Integrations
|
|
||||||
let http_symbols: Vec<_> = model.symbols.values().filter(|s| s.integrations_flags.http).collect();
|
|
||||||
let db_symbols: Vec<_> = model.symbols.values().filter(|s| s.integrations_flags.db).collect();
|
|
||||||
let queue_symbols: Vec<_> = model.symbols.values().filter(|s| s.integrations_flags.queue).collect();
|
|
||||||
|
|
||||||
if !http_symbols.is_empty() || !db_symbols.is_empty() || !queue_symbols.is_empty() {
|
|
||||||
println!("{}", "Detected integrations".bold().underline());
|
|
||||||
if !http_symbols.is_empty() {
|
|
||||||
println!(" {} HTTP: {}", "●".yellow(), http_symbols.iter().map(|s| s.qualname.as_str()).collect::<Vec<_>>().join(", "));
|
|
||||||
}
|
|
||||||
if !db_symbols.is_empty() {
|
|
||||||
println!(" {} DB: {}", "●".blue(), db_symbols.iter().map(|s| s.qualname.as_str()).collect::<Vec<_>>().join(", "));
|
|
||||||
}
|
|
||||||
if !queue_symbols.is_empty() {
|
|
||||||
println!(" {} Queue: {}", "●".magenta(), queue_symbols.iter().map(|s| s.qualname.as_str()).collect::<Vec<_>>().join(", "));
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cycles (basic detection via module import edges)
|
|
||||||
println!("{}", "Cycle detection".bold().underline());
|
|
||||||
let mut found_cycles = false;
|
|
||||||
for edge in &model.edges.module_import_edges {
|
|
||||||
// Check if there's a reverse edge
|
|
||||||
let has_reverse = model.edges.module_import_edges.iter()
|
|
||||||
.any(|e| e.from_id == edge.to_id && e.to_id == edge.from_id);
|
|
||||||
if has_reverse && edge.from_id < edge.to_id {
|
|
||||||
println!(" {} {} ↔ {}", "⚠".red(), edge.from_id, edge.to_id);
|
|
||||||
found_cycles = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found_cycles {
|
|
||||||
println!(" {} No cycles detected", "✓".green());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_docs_consistency(root: &str, config: &Config) -> Result<()> {
|
|
||||||
println!("{}", "Checking documentation consistency...".cyan());
|
|
||||||
|
|
||||||
let model = analyze_project(root, config)?;
|
|
||||||
|
|
||||||
let renderer = archdoc_core::renderer::Renderer::new();
|
|
||||||
let _generated = renderer.render_architecture_md(&model)?;
|
|
||||||
|
|
||||||
let architecture_md_path = std::path::Path::new(root).join(&config.project.entry_file);
|
|
||||||
if !architecture_md_path.exists() {
|
|
||||||
println!("{} {} does not exist", "✗".red().bold(), architecture_md_path.display());
|
|
||||||
return Err(anyhow::anyhow!("Documentation file does not exist"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let existing = std::fs::read_to_string(&architecture_md_path)?;
|
|
||||||
|
|
||||||
println!("{} Documentation is parseable and consistent", "✓".green().bold());
|
|
||||||
println!(" Generated content: {} chars", _generated.len());
|
|
||||||
println!(" Existing content: {} chars", existing.len());
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
33
archdoc-cli/src/output.rs
Normal file
33
archdoc-cli/src/output.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
//! Colored output helpers and filename utilities for ArchDoc CLI
|
||||||
|
|
||||||
|
use colored::Colorize;
|
||||||
|
use archdoc_core::ProjectModel;
|
||||||
|
|
||||||
|
/// Sanitize a file path into a safe filename for docs.
|
||||||
|
/// Removes `./` prefix, replaces `/` with `__`.
|
||||||
|
pub fn sanitize_filename(filename: &str) -> String {
|
||||||
|
let cleaned = filename.strip_prefix("./").unwrap_or(filename);
|
||||||
|
cleaned.replace('/', "__")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_generate_summary(model: &ProjectModel) {
|
||||||
|
println!();
|
||||||
|
println!("{}", "── Summary ──────────────────────────".dimmed());
|
||||||
|
println!(" {} {}", "Files:".bold(), model.files.len());
|
||||||
|
println!(" {} {}", "Modules:".bold(), model.modules.len());
|
||||||
|
println!(" {} {}", "Symbols:".bold(), model.symbols.len());
|
||||||
|
println!(" {} {}", "Edges:".bold(),
|
||||||
|
model.edges.module_import_edges.len() + model.edges.symbol_call_edges.len());
|
||||||
|
|
||||||
|
let integrations: Vec<&str> = {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
if model.symbols.values().any(|s| s.integrations_flags.http) { v.push("HTTP"); }
|
||||||
|
if model.symbols.values().any(|s| s.integrations_flags.db) { v.push("DB"); }
|
||||||
|
if model.symbols.values().any(|s| s.integrations_flags.queue) { v.push("Queue"); }
|
||||||
|
v
|
||||||
|
};
|
||||||
|
if !integrations.is_empty() {
|
||||||
|
println!(" {} {}", "Integrations:".bold(), integrations.join(", ").yellow());
|
||||||
|
}
|
||||||
|
println!("{}", "─────────────────────────────────────".dimmed());
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ impl CacheManager {
|
|||||||
|
|
||||||
// Read cache file
|
// Read cache file
|
||||||
let content = fs::read_to_string(&cache_file)
|
let content = fs::read_to_string(&cache_file)
|
||||||
.map_err(|e| ArchDocError::Io(e))?;
|
.map_err(ArchDocError::Io)?;
|
||||||
|
|
||||||
let cache_entry: CacheEntry = serde_json::from_str(&content)
|
let cache_entry: CacheEntry = serde_json::from_str(&content)
|
||||||
.map_err(|e| ArchDocError::AnalysisError(format!("Failed to deserialize cache entry: {}", e)))?;
|
.map_err(|e| ArchDocError::AnalysisError(format!("Failed to deserialize cache entry: {}", e)))?;
|
||||||
@@ -73,10 +73,10 @@ impl CacheManager {
|
|||||||
|
|
||||||
// Check if source file has been modified since caching
|
// Check if source file has been modified since caching
|
||||||
let metadata = fs::metadata(file_path)
|
let metadata = fs::metadata(file_path)
|
||||||
.map_err(|e| ArchDocError::Io(e))?;
|
.map_err(ArchDocError::Io)?;
|
||||||
|
|
||||||
let modified_time = metadata.modified()
|
let modified_time = metadata.modified()
|
||||||
.map_err(|e| ArchDocError::Io(e))?;
|
.map_err(ArchDocError::Io)?;
|
||||||
|
|
||||||
let modified_time: DateTime<Utc> = modified_time.into();
|
let modified_time: DateTime<Utc> = modified_time.into();
|
||||||
|
|
||||||
@@ -100,10 +100,10 @@ impl CacheManager {
|
|||||||
|
|
||||||
// Get file modification time
|
// Get file modification time
|
||||||
let metadata = fs::metadata(file_path)
|
let metadata = fs::metadata(file_path)
|
||||||
.map_err(|e| ArchDocError::Io(e))?;
|
.map_err(ArchDocError::Io)?;
|
||||||
|
|
||||||
let modified_time = metadata.modified()
|
let modified_time = metadata.modified()
|
||||||
.map_err(|e| ArchDocError::Io(e))?;
|
.map_err(ArchDocError::Io)?;
|
||||||
|
|
||||||
let modified_time: DateTime<Utc> = modified_time.into();
|
let modified_time: DateTime<Utc> = modified_time.into();
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ impl CacheManager {
|
|||||||
.map_err(|e| ArchDocError::AnalysisError(format!("Failed to serialize cache entry: {}", e)))?;
|
.map_err(|e| ArchDocError::AnalysisError(format!("Failed to serialize cache entry: {}", e)))?;
|
||||||
|
|
||||||
fs::write(&cache_file, content)
|
fs::write(&cache_file, content)
|
||||||
.map_err(|e| ArchDocError::Io(e))
|
.map_err(ArchDocError::Io)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate cache key for a file path
|
/// Generate cache key for a file path
|
||||||
@@ -156,11 +156,11 @@ impl CacheManager {
|
|||||||
pub fn clear_cache(&self) -> Result<(), ArchDocError> {
|
pub fn clear_cache(&self) -> Result<(), ArchDocError> {
|
||||||
if Path::new(&self.cache_dir).exists() {
|
if Path::new(&self.cache_dir).exists() {
|
||||||
fs::remove_dir_all(&self.cache_dir)
|
fs::remove_dir_all(&self.cache_dir)
|
||||||
.map_err(|e| ArchDocError::Io(e))?;
|
.map_err(ArchDocError::Io)?;
|
||||||
|
|
||||||
// Recreate cache directory
|
// Recreate cache directory
|
||||||
fs::create_dir_all(&self.cache_dir)
|
fs::create_dir_all(&self.cache_dir)
|
||||||
.map_err(|e| ArchDocError::Io(e))?;
|
.map_err(ArchDocError::Io)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::path::Path;
|
|||||||
use crate::errors::ArchDocError;
|
use crate::errors::ArchDocError;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[derive(Default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub project: ProjectConfig,
|
pub project: ProjectConfig,
|
||||||
@@ -30,22 +31,6 @@ pub struct Config {
|
|||||||
pub caching: CachingConfig,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ProjectConfig {
|
pub struct ProjectConfig {
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ use rustpython_parser::{ast, Parse};
|
|||||||
use rustpython_ast::{Stmt, Expr, Ranged};
|
use rustpython_ast::{Stmt, Expr, Ranged};
|
||||||
|
|
||||||
pub struct PythonAnalyzer {
|
pub struct PythonAnalyzer {
|
||||||
_config: Config,
|
config: Config,
|
||||||
cache_manager: CacheManager,
|
cache_manager: CacheManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PythonAnalyzer {
|
impl PythonAnalyzer {
|
||||||
pub fn new(config: Config) -> Self {
|
pub fn new(config: Config) -> Self {
|
||||||
let cache_manager = CacheManager::new(config.clone());
|
let cache_manager = CacheManager::new(config.clone());
|
||||||
Self { _config: config, cache_manager }
|
Self { config, cache_manager }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_module(&self, file_path: &Path) -> Result<ParsedModule, ArchDocError> {
|
pub fn parse_module(&self, file_path: &Path) -> Result<ParsedModule, ArchDocError> {
|
||||||
@@ -67,7 +67,7 @@ impl PythonAnalyzer {
|
|||||||
imports: &mut Vec<Import>,
|
imports: &mut Vec<Import>,
|
||||||
symbols: &mut Vec<Symbol>,
|
symbols: &mut Vec<Symbol>,
|
||||||
calls: &mut Vec<Call>,
|
calls: &mut Vec<Call>,
|
||||||
depth: usize,
|
_depth: usize,
|
||||||
) {
|
) {
|
||||||
match stmt {
|
match stmt {
|
||||||
Stmt::Import(import_stmt) => {
|
Stmt::Import(import_stmt) => {
|
||||||
@@ -104,7 +104,7 @@ impl PythonAnalyzer {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let signature = self.build_function_signature(&func_def.name, &func_def.args);
|
let signature = self.build_function_signature(&func_def.name, &func_def.args);
|
||||||
let integrations_flags = self.detect_integrations(&func_def.body, &self._config);
|
let integrations_flags = self.detect_integrations(&func_def.body, &self.config);
|
||||||
let docstring = self.extract_docstring(&func_def.body);
|
let docstring = self.extract_docstring(&func_def.body);
|
||||||
|
|
||||||
let symbol = Symbol {
|
let symbol = Symbol {
|
||||||
@@ -130,7 +130,7 @@ impl PythonAnalyzer {
|
|||||||
symbols.push(symbol);
|
symbols.push(symbol);
|
||||||
|
|
||||||
for body_stmt in &func_def.body {
|
for body_stmt in &func_def.body {
|
||||||
self.extract_from_statement(body_stmt, parent_class, imports, symbols, calls, depth + 1);
|
self.extract_from_statement(body_stmt, parent_class, imports, symbols, calls, _depth + 1);
|
||||||
}
|
}
|
||||||
// Extract calls from body expressions recursively
|
// Extract calls from body expressions recursively
|
||||||
self.extract_calls_from_body(&func_def.body, Some(&qualname), calls);
|
self.extract_calls_from_body(&func_def.body, Some(&qualname), calls);
|
||||||
@@ -143,7 +143,7 @@ impl PythonAnalyzer {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let signature = format!("async {}", self.build_function_signature(&func_def.name, &func_def.args));
|
let signature = format!("async {}", self.build_function_signature(&func_def.name, &func_def.args));
|
||||||
let integrations_flags = self.detect_integrations(&func_def.body, &self._config);
|
let integrations_flags = self.detect_integrations(&func_def.body, &self.config);
|
||||||
let docstring = self.extract_docstring(&func_def.body);
|
let docstring = self.extract_docstring(&func_def.body);
|
||||||
|
|
||||||
let symbol = Symbol {
|
let symbol = Symbol {
|
||||||
@@ -169,12 +169,12 @@ impl PythonAnalyzer {
|
|||||||
symbols.push(symbol);
|
symbols.push(symbol);
|
||||||
|
|
||||||
for body_stmt in &func_def.body {
|
for body_stmt in &func_def.body {
|
||||||
self.extract_from_statement(body_stmt, parent_class, imports, symbols, calls, depth + 1);
|
self.extract_from_statement(body_stmt, parent_class, imports, symbols, calls, _depth + 1);
|
||||||
}
|
}
|
||||||
self.extract_calls_from_body(&func_def.body, Some(&qualname), calls);
|
self.extract_calls_from_body(&func_def.body, Some(&qualname), calls);
|
||||||
}
|
}
|
||||||
Stmt::ClassDef(class_def) => {
|
Stmt::ClassDef(class_def) => {
|
||||||
let integrations_flags = self.detect_integrations(&class_def.body, &self._config);
|
let integrations_flags = self.detect_integrations(&class_def.body, &self.config);
|
||||||
let docstring = self.extract_docstring(&class_def.body);
|
let docstring = self.extract_docstring(&class_def.body);
|
||||||
|
|
||||||
let symbol = Symbol {
|
let symbol = Symbol {
|
||||||
@@ -201,7 +201,7 @@ impl PythonAnalyzer {
|
|||||||
|
|
||||||
// Process class body with class name as parent
|
// Process class body with class name as parent
|
||||||
for body_stmt in &class_def.body {
|
for body_stmt in &class_def.body {
|
||||||
self.extract_from_statement(body_stmt, Some(&class_def.name), imports, symbols, calls, depth + 1);
|
self.extract_from_statement(body_stmt, Some(&class_def.name), imports, symbols, calls, _depth + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Stmt::Expr(expr_stmt) => {
|
Stmt::Expr(expr_stmt) => {
|
||||||
@@ -346,10 +346,10 @@ impl PythonAnalyzer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn extract_docstring(&self, body: &[Stmt]) -> Option<String> {
|
fn extract_docstring(&self, body: &[Stmt]) -> Option<String> {
|
||||||
if let Some(first_stmt) = body.first() {
|
if let Some(first_stmt) = body.first()
|
||||||
if let Stmt::Expr(expr_stmt) = first_stmt {
|
&& let Stmt::Expr(expr_stmt) = first_stmt
|
||||||
if let Expr::Constant(constant_expr) = &*expr_stmt.value {
|
&& let Expr::Constant(constant_expr) = &*expr_stmt.value
|
||||||
if let Some(docstring) = constant_expr.value.as_str() {
|
&& let Some(docstring) = constant_expr.value.as_str() {
|
||||||
// Return full docstring, trimmed
|
// Return full docstring, trimmed
|
||||||
let trimmed = docstring.trim();
|
let trimmed = docstring.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -357,9 +357,6 @@ impl PythonAnalyzer {
|
|||||||
}
|
}
|
||||||
return Some(trimmed.to_string());
|
return Some(trimmed.to_string());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,10 +443,8 @@ impl PythonAnalyzer {
|
|||||||
self.extract_from_expression(&if_exp.orelse, current_symbol, calls);
|
self.extract_from_expression(&if_exp.orelse, current_symbol, calls);
|
||||||
}
|
}
|
||||||
Expr::Dict(dict_expr) => {
|
Expr::Dict(dict_expr) => {
|
||||||
for key in &dict_expr.keys {
|
for k in dict_expr.keys.iter().flatten() {
|
||||||
if let Some(k) = key {
|
self.extract_from_expression(k, current_symbol, calls);
|
||||||
self.extract_from_expression(k, current_symbol, calls);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for value in &dict_expr.values {
|
for value in &dict_expr.values {
|
||||||
self.extract_from_expression(value, current_symbol, calls);
|
self.extract_from_expression(value, current_symbol, calls);
|
||||||
@@ -522,6 +517,55 @@ impl PythonAnalyzer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute Python module path from file path using src_roots from config.
|
||||||
|
/// E.g. `./src/core.py` with src_root `src` → `core`
|
||||||
|
/// `./src/__init__.py` with src_root `src` → `src` (package)
|
||||||
|
/// `back-end/services/chat/agent.py` with src_root `.` → `back-end.services.chat.agent`
|
||||||
|
fn compute_module_path(&self, file_path: &Path) -> String {
|
||||||
|
let path_str = file_path.to_string_lossy().to_string();
|
||||||
|
// Normalize: strip leading ./
|
||||||
|
let normalized = path_str.strip_prefix("./").unwrap_or(&path_str);
|
||||||
|
let path = std::path::Path::new(normalized);
|
||||||
|
|
||||||
|
for src_root in &self.config.python.src_roots {
|
||||||
|
let root = if src_root == "." {
|
||||||
|
std::path::Path::new("")
|
||||||
|
} else {
|
||||||
|
std::path::Path::new(src_root)
|
||||||
|
};
|
||||||
|
|
||||||
|
let relative = if root == std::path::Path::new("") {
|
||||||
|
Some(path.to_path_buf())
|
||||||
|
} else {
|
||||||
|
path.strip_prefix(root).ok().map(|p| p.to_path_buf())
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(rel) = relative {
|
||||||
|
let rel_str = rel.to_string_lossy().to_string();
|
||||||
|
// Check if it's an __init__.py → use the parent directory name as module
|
||||||
|
if rel.file_name().map(|f| f == "__init__.py").unwrap_or(false)
|
||||||
|
&& let Some(parent) = rel.parent() {
|
||||||
|
if parent == std::path::Path::new("") {
|
||||||
|
// __init__.py at src_root level → use src_root as module name
|
||||||
|
if src_root == "." {
|
||||||
|
return "__init__".to_string();
|
||||||
|
}
|
||||||
|
return src_root.replace('/', ".");
|
||||||
|
}
|
||||||
|
return parent.to_string_lossy().replace(['/', '\\'], ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip .py extension and convert path separators to dots
|
||||||
|
let without_ext = rel_str.strip_suffix(".py").unwrap_or(&rel_str);
|
||||||
|
let module_path = without_ext.replace(['/', '\\'], ".");
|
||||||
|
return module_path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use file path as-is
|
||||||
|
normalized.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_symbols(&self, modules: &[ParsedModule]) -> Result<ProjectModel, ArchDocError> {
|
pub fn resolve_symbols(&self, modules: &[ParsedModule]) -> Result<ProjectModel, ArchDocError> {
|
||||||
let mut project_model = ProjectModel::new();
|
let mut project_model = ProjectModel::new();
|
||||||
|
|
||||||
@@ -537,7 +581,7 @@ impl PythonAnalyzer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for parsed_module in modules {
|
for parsed_module in modules {
|
||||||
let module_id = parsed_module.module_path.clone();
|
let module_id = self.compute_module_path(&parsed_module.path);
|
||||||
let file_id = parsed_module.path.to_string_lossy().to_string();
|
let file_id = parsed_module.path.to_string_lossy().to_string();
|
||||||
|
|
||||||
let file_doc = FileDoc {
|
let file_doc = FileDoc {
|
||||||
@@ -625,7 +669,7 @@ impl PythonAnalyzer {
|
|||||||
|
|
||||||
fn build_dependency_graphs(&self, project_model: &mut ProjectModel, parsed_modules: &[ParsedModule]) -> Result<(), ArchDocError> {
|
fn build_dependency_graphs(&self, project_model: &mut ProjectModel, parsed_modules: &[ParsedModule]) -> Result<(), ArchDocError> {
|
||||||
for parsed_module in parsed_modules {
|
for parsed_module in parsed_modules {
|
||||||
let from_module_id = parsed_module.module_path.clone();
|
let from_module_id = self.compute_module_path(&parsed_module.path);
|
||||||
|
|
||||||
for import in &parsed_module.imports {
|
for import in &parsed_module.imports {
|
||||||
let to_module_id = import.module_name.clone();
|
let to_module_id = import.module_name.clone();
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ pub struct Renderer {
|
|||||||
templates: Handlebars<'static>,
|
templates: Handlebars<'static>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for Renderer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Renderer {
|
impl Renderer {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let mut handlebars = Handlebars::new();
|
let mut handlebars = Handlebars::new();
|
||||||
@@ -393,7 +399,7 @@ impl Renderer {
|
|||||||
// Collect layout information from files
|
// Collect layout information from files
|
||||||
let mut layout_items = Vec::new();
|
let mut layout_items = Vec::new();
|
||||||
|
|
||||||
for (_file_id, file_doc) in &model.files {
|
for file_doc in model.files.values() {
|
||||||
layout_items.push(serde_json::json!({
|
layout_items.push(serde_json::json!({
|
||||||
"path": file_doc.path,
|
"path": file_doc.path,
|
||||||
"purpose": "Source file",
|
"purpose": "Source file",
|
||||||
@@ -525,7 +531,7 @@ impl Renderer {
|
|||||||
// Collect layout information from files
|
// Collect layout information from files
|
||||||
let mut layout_items = Vec::new();
|
let mut layout_items = Vec::new();
|
||||||
|
|
||||||
for (_file_id, file_doc) in &model.files {
|
for file_doc in model.files.values() {
|
||||||
layout_items.push(serde_json::json!({
|
layout_items.push(serde_json::json!({
|
||||||
"path": file_doc.path,
|
"path": file_doc.path,
|
||||||
"purpose": "Source file",
|
"purpose": "Source file",
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ impl FileScanner {
|
|||||||
.into_iter() {
|
.into_iter() {
|
||||||
|
|
||||||
let entry = entry.map_err(|e| {
|
let entry = entry.map_err(|e| {
|
||||||
ArchDocError::Io(std::io::Error::new(
|
ArchDocError::Io(std::io::Error::other(
|
||||||
std::io::ErrorKind::Other,
|
|
||||||
format!("Failed to read directory entry: {}", e)
|
format!("Failed to read directory entry: {}", e)
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
@@ -51,11 +50,7 @@ impl FileScanner {
|
|||||||
|
|
||||||
// Skip excluded paths
|
// Skip excluded paths
|
||||||
if self.is_excluded(path) {
|
if self.is_excluded(path) {
|
||||||
if path.is_dir() {
|
continue;
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include Python files
|
// Include Python files
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ pub struct DiffAwareWriter {
|
|||||||
// Configuration
|
// Configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for DiffAwareWriter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl DiffAwareWriter {
|
impl DiffAwareWriter {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {}
|
Self {}
|
||||||
@@ -40,13 +46,13 @@ impl DiffAwareWriter {
|
|||||||
// Read existing file
|
// Read existing file
|
||||||
let existing_content = if file_path.exists() {
|
let existing_content = if file_path.exists() {
|
||||||
fs::read_to_string(file_path)
|
fs::read_to_string(file_path)
|
||||||
.map_err(|e| ArchDocError::Io(e))?
|
.map_err(ArchDocError::Io)?
|
||||||
} else {
|
} else {
|
||||||
// Create new file with template
|
// Create new file with template
|
||||||
let template_content = self.create_template_file(file_path, section_name)?;
|
let template_content = self.create_template_file(file_path, section_name)?;
|
||||||
// Write template to file
|
// Write template to file
|
||||||
fs::write(file_path, &template_content)
|
fs::write(file_path, &template_content)
|
||||||
.map_err(|e| ArchDocError::Io(e))?;
|
.map_err(ArchDocError::Io)?;
|
||||||
template_content
|
template_content
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,12 +74,12 @@ impl DiffAwareWriter {
|
|||||||
if content_changed {
|
if content_changed {
|
||||||
let updated_content = self.update_timestamp(new_content)?;
|
let updated_content = self.update_timestamp(new_content)?;
|
||||||
fs::write(file_path, updated_content)
|
fs::write(file_path, updated_content)
|
||||||
.map_err(|e| ArchDocError::Io(e))?;
|
.map_err(ArchDocError::Io)?;
|
||||||
} else {
|
} else {
|
||||||
// Content hasn't changed, but we might still need to update timestamp
|
// Content hasn't changed, but we might still need to update timestamp
|
||||||
// TODO: Implement timestamp update logic based on config
|
// TODO: Implement timestamp update logic based on config
|
||||||
fs::write(file_path, new_content)
|
fs::write(file_path, new_content)
|
||||||
.map_err(|e| ArchDocError::Io(e))?;
|
.map_err(ArchDocError::Io)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +95,12 @@ impl DiffAwareWriter {
|
|||||||
// Read existing file
|
// Read existing file
|
||||||
let existing_content = if file_path.exists() {
|
let existing_content = if file_path.exists() {
|
||||||
fs::read_to_string(file_path)
|
fs::read_to_string(file_path)
|
||||||
.map_err(|e| ArchDocError::Io(e))?
|
.map_err(ArchDocError::Io)?
|
||||||
} else {
|
} else {
|
||||||
// If file doesn't exist, create it with a basic template
|
// If file doesn't exist, create it with a basic template
|
||||||
let template_content = self.create_template_file(file_path, "symbol")?;
|
let template_content = self.create_template_file(file_path, "symbol")?;
|
||||||
fs::write(file_path, &template_content)
|
fs::write(file_path, &template_content)
|
||||||
.map_err(|e| ArchDocError::Io(e))?;
|
.map_err(ArchDocError::Io)?;
|
||||||
template_content
|
template_content
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,12 +122,12 @@ impl DiffAwareWriter {
|
|||||||
if content_changed {
|
if content_changed {
|
||||||
let updated_content = self.update_timestamp(new_content)?;
|
let updated_content = self.update_timestamp(new_content)?;
|
||||||
fs::write(file_path, updated_content)
|
fs::write(file_path, updated_content)
|
||||||
.map_err(|e| ArchDocError::Io(e))?;
|
.map_err(ArchDocError::Io)?;
|
||||||
} else {
|
} else {
|
||||||
// Content hasn't changed, but we might still need to update timestamp
|
// Content hasn't changed, but we might still need to update timestamp
|
||||||
// TODO: Implement timestamp update logic based on config
|
// TODO: Implement timestamp update logic based on config
|
||||||
fs::write(file_path, new_content)
|
fs::write(file_path, new_content)
|
||||||
.map_err(|e| ArchDocError::Io(e))?;
|
.map_err(ArchDocError::Io)?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
eprintln!("Warning: No symbol marker found for {} in {}", symbol_id, file_path.display());
|
eprintln!("Warning: No symbol marker found for {} in {}", symbol_id, file_path.display());
|
||||||
|
|||||||
Reference in New Issue
Block a user