Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

CamadaComo testar
store::frontmatterunit: round-trip YAML ↔ struct
store::slugunit: casos de normalização e validação
index::queryintegração: banco em memória (:memory:) com fixtures
commandsintegração: workspace temporário em tempfile::tempdir()
CLI completotests/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:

  1. config/ + store/workspace.rs + cli.rs mínimo → syn setup, syn init
  2. model/ + store/frontmatter.rs → entidades existem como arquivos
  3. index/ (schema + reindex + query FTS) → syn search funciona
  4. commands/{project,person,api} add/list/show/rm → base utilizável
  5. i18n.rs + .ftl → tudo traduzido (estruture as chaves desde o passo 1)
  6. mcp/ → Claude Code consulta a base
  7. Resto: task, tag, doctor, export, completions