Synapse: Arquitetura do Projeto Rust
Documento de referência para a organização do código: estrutura de src/,
como o clap derive mapeia os comandos, e como as camadas se conversam.
Princípio de organização
O código segue a separação borda → núcleo → dados:
- Borda (
cli,i18n, output): traduz intenção do usuário em chamadas. Não contém lógica de negócio. - Núcleo (
commands,model): a lógica. Não sabe que existe um terminal. - Dados (
store,index,config): persistência e leitura. Não sabe o que é um comando.
A regra de ouro: dependências apontam para dentro. commands chama store,
nunca o contrário. Isso mantém o núcleo testável sem terminal e sem disco real.
Layout de diretórios
synapse/
├── Cargo.toml
├── build.rs # injeta git hash na versão
├── i18n.toml # config do cargo-i18n
├── i18n/
│ ├── en/synapse.ftl
│ └── pt-BR/synapse.ftl
├── src/
│ ├── main.rs # entrypoint: parse args, dispatch, trata erro
│ ├── cli.rs # definição clap (structs + enums de subcomando)
│ ├── error.rs # tipos de erro (thiserror)
│ ├── i18n.rs # loader Fluent + macro de tradução
│ ├── output.rs # formatação de saída (text/json), cores
│ │
│ ├── config/
│ │ ├── mod.rs # API pública do módulo
│ │ ├── cascade.rs # resolução sistema→global→local→runtime
│ │ └── paths.rs # diretórios XDG/nativos (crate `directories`)
│ │
│ ├── model/
│ │ ├── mod.rs
│ │ ├── entity.rs # enum Entity + traits comuns
│ │ ├── project.rs # struct Project + frontmatter
│ │ ├── person.rs
│ │ ├── api.rs
│ │ ├── task.rs
│ │ └── link.rs # vínculos do grafo
│ │
│ ├── store/ # leitura/escrita dos .md (fonte da verdade)
│ │ ├── mod.rs
│ │ ├── frontmatter.rs # parse/serialize YAML <-> struct
│ │ ├── slug.rs # geração e validação de slugs
│ │ └── workspace.rs # localizar raiz do workspace (.synapse/)
│ │
│ ├── index/ # SQLite: índice descartável
│ │ ├── mod.rs
│ │ ├── schema.rs # DDL das tabelas + migrations
│ │ ├── reindex.rs # varredura e repopulação
│ │ └── query.rs # busca FTS5, filtros, grafo
│ │
│ ├── commands/ # um arquivo por grupo de comando
│ │ ├── mod.rs # trait Command { fn run(&self, ctx) }
│ │ ├── setup.rs
│ │ ├── init.rs
│ │ ├── config.rs
│ │ ├── project.rs
│ │ ├── person.rs
│ │ ├── entity.rs
│ │ ├── api.rs
│ │ ├── task.rs
│ │ ├── tag.rs
│ │ ├── search.rs
│ │ ├── mcp.rs
│ │ └── info.rs
│ │
│ └── mcp/ # servidor MCP sobre stdio
│ ├── mod.rs
│ ├── server.rs # loop de protocolo
│ └── tools.rs # ferramentas expostas (search, get, list...)
└── tests/
├── cli.rs # testes de integração end-to-end
└── fixtures/ # workspaces de exemplo
A definição clap (cli.rs)
A estrutura segue o padrão de produção: um Cli no topo com flags globais, e
um enum Commands com os subcomandos. Subcomandos com sub-níveis (como
project add) usam enums aninhados.
#![allow(unused)]
fn main() {
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "syn", author, version, about)]
#[command(propagate_version = true)]
pub struct Cli {
/// Saída detalhada
#[arg(short, long, global = true)]
pub verbose: bool,
/// Formato de saída
#[arg(long, global = true, default_value = "text")]
pub format: OutputFormat,
/// Sobrescreve o idioma (ex: en, pt-BR)
#[arg(long, global = true, env = "SYNAPSE_LANG")]
pub lang: Option<String>,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Configuração global inicial (interativo)
Setup,
/// Inicializa um workspace no diretório atual
Init { path: Option<PathBuf> },
/// Gerencia configuração
Config(ConfigArgs),
/// Gerencia projetos
Project(ProjectArgs),
/// Gerencia pessoas
Person(PersonArgs),
/// Gerencia APIs
Api(ApiArgs),
/// Gerencia demandas
Task(TaskArgs),
/// Busca na base
Search(SearchArgs),
/// Controla o servidor MCP
Mcp(McpArgs),
/// Mostra estado do workspace
Info,
/// Reconstrói o índice
Reindex,
/// Diagnóstico de saúde
Doctor,
}
// Exemplo de subcomando com níveis aninhados
#[derive(clap::Args)]
pub struct ProjectArgs {
#[command(subcommand)]
pub action: ProjectAction,
}
#[derive(Subcommand)]
pub enum ProjectAction {
Add {
name: String,
#[arg(long)]
desc: Option<String>,
#[arg(long, default_value = "ativo")]
status: String,
},
List {
#[arg(long)]
status: Option<String>,
#[arg(long)]
tag: Option<String>,
},
Show { name: String },
Rm {
name: String,
#[arg(long)]
force: bool,
},
Rename { old: String, new: String },
}
#[derive(Clone, clap::ValueEnum)]
pub enum OutputFormat { Text, Json }
}
O dispatch (main.rs)
O main é fino: monta o contexto, despacha, e formata o erro de saída. Toda a
lógica vive em commands.
use anyhow::Result;
use clap::Parser;
fn main() {
if let Err(e) = run() {
// erro traduzido e colorido, código de saída != 0
output::print_error(&e);
std::process::exit(1);
}
}
fn run() -> Result<()> {
let cli = cli::Cli::parse();
// 1. resolve config em cascata + idioma
let config = config::load(cli.lang.as_deref())?;
let loader = i18n::init(&config.lang);
// 2. monta contexto compartilhado passado a todo comando
let ctx = Context {
config,
loader,
format: cli.format,
verbose: cli.verbose,
workspace: store::workspace::find(), // Option, nem todo cmd exige
};
// 3. despacha
commands::dispatch(cli.command, &ctx)
}
A trait Command (commands/mod.rs)
Cada comando implementa uma interface comum. Isso uniformiza tratamento de erro, acesso ao contexto e testabilidade.
#![allow(unused)]
fn main() {
pub struct Context {
pub config: Config,
pub loader: FluentLanguageLoader,
pub format: OutputFormat,
pub verbose: bool,
pub workspace: Option<Workspace>,
}
pub fn dispatch(cmd: Commands, ctx: &Context) -> anyhow::Result<()> {
match cmd {
Commands::Setup => setup::run(ctx),
Commands::Init { path } => init::run(path, ctx),
Commands::Project(a) => project::run(a, ctx),
Commands::Search(a) => search::run(a, ctx),
Commands::Mcp(a) => mcp::run(a, ctx),
// ...
}
}
}
Comandos que exigem um workspace (quase todos exceto setup e init) começam
com uma checagem que falha cedo e com mensagem clara:
#![allow(unused)]
fn main() {
let ws = ctx.workspace.as_ref()
.ok_or_else(|| Error::NoWorkspace)?; // "não está em um workspace synapse"
}
Tratamento de erros (error.rs)
Padrão consolidado em Rust: thiserror para erros tipados da biblioteca,
anyhow para propagação ergonômica na aplicação. Os erros carregam chaves de
tradução, não strings fixas: assim a mensagem sai no idioma do usuário.
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("error-no-workspace")] // chave Fluent, não texto final
NoWorkspace,
#[error("error-entity-exists")]
EntityExists { id: String },
#[error("error-entity-not-found")]
NotFound { id: String },
#[error("error-invalid-slug")]
InvalidSlug { input: String },
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Sqlite(#[from] rusqlite::Error),
}
}
A camada output::print_error recebe o Error, pega a chave, e resolve via
Fluent com os parâmetros: separando a identidade do erro da sua redação.
Camada de i18n (i18n.rs)
Usa i18n-embed com Fluent. Os .ftl são embutidos no binário em tempo de
compilação via rust-embed, então o binário final não tem dependência externa
de arquivos de tradução.
#![allow(unused)]
fn main() {
use i18n_embed::fluent::{fluent_language_loader, FluentLanguageLoader};
use i18n_embed::{DesktopLanguageRequester, LanguageRequester};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "i18n/"]
struct Localizations;
pub fn init(lang: &str) -> FluentLanguageLoader {
let loader = fluent_language_loader!();
// carrega fallback (en) + idioma pedido
loader.load_languages(&Localizations, &[lang.parse().unwrap()]).ok();
loader
}
}
No código de comando, traduzir é uma macro com checagem em tempo de compilação:
#![allow(unused)]
fn main() {
use i18n_embed_fl::fl;
println!("{}", fl!(ctx.loader, "project-created", name = name));
}
Detecção do idioma da máquina no setup usa sys-locale:
#![allow(unused)]
fn main() {
let detected = sys_locale::get_locale() // ex: "pt-BR"
.unwrap_or_else(|| "en".to_string());
}
Camada de saída (output.rs)
Centraliza como as coisas aparecem, respeitando --format. Comandos
devolvem dados estruturados; o output decide entre tabela colorida (humano)
ou JSON (scripting/máquina). Isso é o que permite o --format json funcionar
uniformemente em todo comando sem código duplicado.
#![allow(unused)]
fn main() {
pub fn render<T: Serialize + TableDisplay>(data: T, fmt: OutputFormat) {
match fmt {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&data).unwrap()),
OutputFormat::Text => print_table(data),
}
}
}
Camada MCP (mcp/)
O servidor MCP roda sobre stdio (é assim que o Claude Code consome). Ele
reusa exatamente as mesmas funções de index::query que o syn search usa, não há lógica duplicada entre “buscar pelo CLI” e “buscar pelo MCP”. As
ferramentas expostas mapeiam 1:1 para operações de leitura da base:
#![allow(unused)]
fn main() {
// tools.rs: cada ferramenta MCP é uma fina casca sobre index::query
pub fn tools() -> Vec<Tool> {
vec![
Tool::new("search_knowledge", "Busca textual na base"),
Tool::new("get_project", "Detalhes de um projeto"),
Tool::new("find_person", "Encontra pessoa e seus vínculos"),
Tool::new("list_apis", "Lista APIs, com filtros"),
Tool::new("project_graph", "Relações de um projeto"),
]
}
}
A implementação do protocolo pode usar o SDK oficial de MCP para Rust quando
disponível, ou ser construída sobre tokio + serde_json lendo/escrevendo
mensagens JSON-RPC no stdio. O ponto arquitetural importante: o MCP é só mais
uma borda, como o CLI. Ambos chamam o mesmo núcleo.
Estratégia de testes
| Camada | Como testar |
|---|---|
store::frontmatter | unit: round-trip YAML ↔ struct |
store::slug | unit: casos de normalização e validação |
index::query | integração: banco em memória (:memory:) com fixtures |
commands | integração: workspace temporário em tempfile::tempdir() |
| CLI completo | tests/cli.rs com assert_cmd rodando o binário real |
O padrão recomendado para testar a init: criar um diretório temporário,
rodar syn init, e verificar que a estrutura de pastas e o index.db foram
criados, e que rodar de novo no mesmo lugar falha com erro claro (idempotência
e proteção contra sobrescrita).
build.rs: versão com git hash
Convenção comum em CLIs: a versão exibida combina a versão do crate com o hash do commit, útil para suporte (“qual build você está rodando?”).
// build.rs
fn main() {
let hash = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output().ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default();
println!("cargo:rustc-env=SYNAPSE_GIT_HASH={}", hash.trim());
}
Ordem de implementação dos módulos
Para não construir tudo de uma vez, a sequência que mantém o projeto sempre rodável:
config/+store/workspace.rs+cli.rsmínimo →syn setup,syn initmodel/+store/frontmatter.rs→ entidades existem como arquivosindex/(schema + reindex + query FTS) →syn searchfuncionacommands/{project,person,api}add/list/show/rm → base utilizáveli18n.rs+.ftl→ tudo traduzido (estruture as chaves desde o passo 1)mcp/→ Claude Code consulta a base- Resto:
task,tag,doctor,export, completions