roteiro 1.1.0

Roteiro: a provenance-tagged knowledge graph for your codebase — structure, intent, and context in one queryable store
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
//! Layered project/user configuration (ADR-0007).
//!
//! Reads an optional **project** `roteiro.toml` (at the repository root — the
//! nearest ancestor holding a `.git` entry) and an optional **user**
//! `~/.roteiro/config.toml`, then
//! merges them so that, per value, the precedence is
//! **CLI flag > project > user > built-in default**. This module resolves the
//! *config* layers (project over user); the CLI-vs-config precedence is applied
//! at each call site (a flag, when present, wins over the resolved config).
//!
//! Every field is optional and every consumer has a built-in default, so **no
//! config is a working default**. TOML only (YAML is intentionally unsupported —
//! `serde_yaml` is unmaintained). Unknown keys are ignored (forward-compatible);
//! a malformed file is a hard error, never a silent partial parse.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

/// The merged configuration plus the layers it came from (for provenance).
#[derive(Debug, Default)]
pub struct Loaded {
    /// The effective, merged config used at run time (project over user).
    pub effective: Config,
    /// The user-layer config (`~/.roteiro/config.toml`), for provenance.
    pub user: Config,
    /// The project-layer config (`roteiro.toml`), for provenance.
    pub project: Config,
    /// Path the user config was read from, if present.
    pub user_path: Option<PathBuf>,
    /// Path the project config was read from, if present.
    pub project_path: Option<PathBuf>,
}

/// Roteiro configuration. All fields optional; see the module docs.
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct Config {
    /// Per-project model picks (override the tier defaults).
    pub models: ModelsConfig,
    /// `roteiro infer` tuning.
    pub infer: InferConfig,
    /// `roteiro duplicates` tuning.
    pub duplicates: DuplicatesConfig,
    /// `roteiro sync` content-ingestion toggles.
    pub ingest: IngestConfig,
    /// `roteiro serve --models` local endpoint settings.
    pub serve: ServeConfig,
    /// `roteiro debt` tuning (paths excluded from the intent-debt scan).
    pub debt: DebtConfig,
    /// Filesystem locations (the model store).
    pub paths: PathsConfig,
    /// `roteiro serve --workspace` — repos a single server can host (ADR-0008).
    pub workspace: WorkspaceConfig,
    /// `[[links]]` — authored cross-repo links to other workspace repos (ADR-0009).
    pub links: Vec<LinkDecl>,
    /// `[pins]` — how to map a deployed artifact to a hub git ref when the default
    /// tag guess doesn't fit this project's scheme (ADR-0009 step 8c). Keyed by the
    /// hub/image name; value is a ref template with a `{tag}` placeholder, e.g.
    /// `app = "release-{tag}"` maps image `app:1.2` → git ref `release-1.2`.
    pub pins: std::collections::BTreeMap<String, String>,
}

/// `[debt]` — intent-debt reporting.
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct DebtConfig {
    /// Glob patterns whose matching files are excluded from the intent-debt
    /// report (e.g. `["vendor/**", "**/generated/*"]`). Patterns are matched
    /// anchored end-to-end against the whole repo-relative path (not a substring
    /// match): `*`/`?` match within a path segment, `**` matches across segments.
    pub ignore: Option<Vec<String>>,
}

/// `[paths]` — filesystem locations.
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct PathsConfig {
    /// The model store directory (default `~/.roteiro/models`, or
    /// `$ROTEIRO_HOME/models`). A leading `~/` is expanded to the home directory.
    pub model_store: Option<String>,
}

/// `[workspace]` — the repos one `roteiro serve` can host (ADR-0008). Naturally a
/// user-layer setting (machine-specific), but merged like any table. Combined
/// with `serve --workspace <root>`; empty ⇒ single-repo serve (the cwd's repo).
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct WorkspaceConfig {
    /// Directories to scan for git repos (each immediate subdirectory that is a
    /// repo becomes a project, plus the root itself if it is one).
    pub roots: Option<Vec<String>>,
    /// Explicit repo paths to host, in addition to anything found under `roots`.
    pub repos: Option<Vec<String>>,
}

/// One authored cross-repo link (ADR-0009): a `[[links]]` entry in a spoke repo's
/// `roteiro.toml` declaring that this repo references a project-qualified key in
/// another workspace repo. `roteiro links` resolves each against the workspace and
/// flags drift (targets that no longer exist).
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
pub struct LinkDecl {
    /// The project-qualified target: `<project>::<key>`, e.g.
    /// `app::sym:rust:crates/roteiro/src/config.rs#ServeConfig`.
    pub to: String,
    /// Optional local anchor in *this* repo the link originates from, e.g.
    /// `file:values.prod.yaml`. Recorded for provenance and shown in the report;
    /// not currently resolved against this repo's graph.
    #[serde(default)]
    pub from: Option<String>,
    /// Relationship label for display (default `references`).
    #[serde(default)]
    pub kind: Option<String>,
}

/// `[models]` — override the registry tier defaults for this project.
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct ModelsConfig {
    /// Registry name of the embedding model `infer --model` defaults to.
    pub embedding: Option<String>,
    /// Registry name of the generative model `spec draft` defaults to.
    pub generative: Option<String>,
}

/// `[ingest]` — which blob content `roteiro sync` extracts for embedding. Each
/// toggle is `Some(false)` to disable a content class the binary supports; unset
/// (or `true`) leaves it on. A toggle cannot enable a class the binary was not
/// built with (the `pdf-text`/`image-ocr`/`image-vision`/`audio-transcribe`
/// features).
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct IngestConfig {
    /// Embed the UTF-8 body of prose files (Markdown, plain text).
    pub prose: Option<bool>,
    /// Extract text from PDF documents.
    pub pdf: Option<bool>,
    /// OCR literal text from images.
    pub ocr: Option<bool>,
    /// Describe images with a vision model.
    pub vision: Option<bool>,
    /// Transcribe spoken-word audio.
    pub audio: Option<bool>,
}

impl IngestConfig {
    /// Resolve to the graph-layer [`rto_graph::IngestConfig`], defaulting each
    /// unset toggle to on.
    #[must_use]
    pub fn resolve(&self) -> rto_graph::IngestConfig {
        let default = rto_graph::IngestConfig::default();
        rto_graph::IngestConfig {
            prose: self.prose.unwrap_or(default.prose),
            pdf: self.pdf.unwrap_or(default.pdf),
            ocr: self.ocr.unwrap_or(default.ocr),
            vision: self.vision.unwrap_or(default.vision),
            audio: self.audio.unwrap_or(default.audio),
        }
    }
}

/// `[serve]` — the opt-in local OpenAI-compatible model endpoint (ADR-0006).
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct ServeConfig {
    /// Bind address for `roteiro serve --models` (default `127.0.0.1:8017`).
    pub addr: Option<String>,
    /// Restrict which installed generative models to serve (default: all
    /// installed generative models).
    pub models: Option<Vec<String>>,
    /// Auto-register the graph tools so the served model can query the codebase
    /// (ADR-0006). Default `true`.
    pub tools: Option<bool>,
    /// Approximate memory budget (MiB) for models kept resident at once. The
    /// engine loads models on demand and unloads the least-recently-used once the
    /// resident set (proxied by GGUF size) exceeds this. Unset/`0` keeps a single
    /// model resident — set it higher to keep several warm and swap in real time.
    pub memory_budget_mb: Option<u64>,
    /// PEM certificate-chain file for in-app TLS. Set **both** this and `tls_key`
    /// and `serve --models` terminates HTTPS itself (needs `--features serve`);
    /// set **neither** and it serves plain HTTP (front with a proxy for TLS).
    /// Setting exactly one is a startup error.
    pub tls_cert: Option<String>,
    /// PEM private-key file paired with `tls_cert` (PKCS#8 or RSA).
    pub tls_key: Option<String>,
}

/// `[infer]` — defaults for the similarity-inference command.
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct InferConfig {
    /// Minimum cosine similarity for a suggested edge (`0.0..=1.0`).
    pub min_confidence: Option<f64>,
    /// Maximum suggestions per node.
    pub top_k: Option<usize>,
}

/// `[duplicates]` — defaults for the duplicate-detection command.
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(default)]
pub struct DuplicatesConfig {
    /// Minimum cosine similarity for a near-duplicate pair (`0.0..=1.0`).
    pub min_similarity: Option<f64>,
    /// Maximum pairs to report.
    pub limit: Option<usize>,
}

impl Config {
    /// Overlay `over` on top of `self`, taking `over`'s value wherever set — used
    /// to apply the project layer on top of the user layer.
    fn overlaid_with(&self, over: &Config) -> Config {
        Config {
            models: ModelsConfig {
                embedding: over
                    .models
                    .embedding
                    .clone()
                    .or(self.models.embedding.clone()),
                generative: over
                    .models
                    .generative
                    .clone()
                    .or(self.models.generative.clone()),
            },
            infer: InferConfig {
                min_confidence: over.infer.min_confidence.or(self.infer.min_confidence),
                top_k: over.infer.top_k.or(self.infer.top_k),
            },
            duplicates: DuplicatesConfig {
                min_similarity: over
                    .duplicates
                    .min_similarity
                    .or(self.duplicates.min_similarity),
                limit: over.duplicates.limit.or(self.duplicates.limit),
            },
            ingest: IngestConfig {
                prose: over.ingest.prose.or(self.ingest.prose),
                pdf: over.ingest.pdf.or(self.ingest.pdf),
                ocr: over.ingest.ocr.or(self.ingest.ocr),
                vision: over.ingest.vision.or(self.ingest.vision),
                audio: over.ingest.audio.or(self.ingest.audio),
            },
            serve: ServeConfig {
                addr: over.serve.addr.clone().or(self.serve.addr.clone()),
                models: over.serve.models.clone().or(self.serve.models.clone()),
                tools: over.serve.tools.or(self.serve.tools),
                memory_budget_mb: over.serve.memory_budget_mb.or(self.serve.memory_budget_mb),
                tls_cert: over.serve.tls_cert.clone().or(self.serve.tls_cert.clone()),
                tls_key: over.serve.tls_key.clone().or(self.serve.tls_key.clone()),
            },
            debt: DebtConfig {
                ignore: over.debt.ignore.clone().or(self.debt.ignore.clone()),
            },
            paths: PathsConfig {
                model_store: over
                    .paths
                    .model_store
                    .clone()
                    .or(self.paths.model_store.clone()),
            },
            workspace: WorkspaceConfig {
                roots: over
                    .workspace
                    .roots
                    .clone()
                    .or(self.workspace.roots.clone()),
                repos: over
                    .workspace
                    .repos
                    .clone()
                    .or(self.workspace.repos.clone()),
            },
            // Links are per-repo (a spoke declares its own); the project layer wins
            // outright when it has any, else the user layer's (rare).
            links: if over.links.is_empty() {
                self.links.clone()
            } else {
                over.links.clone()
            },
            // Pins merge per key: the project layer overrides the user layer.
            pins: {
                let mut m = self.pins.clone();
                m.extend(over.pins.clone());
                m
            },
        }
    }
}

/// Load and merge the user and project config layers, starting the project
/// search from `cwd`.
///
/// # Errors
/// Returns an error if a config file exists but cannot be read or parsed
/// (malformed TOML is a hard error, never a silent partial parse).
pub fn load(cwd: &Path) -> anyhow::Result<Loaded> {
    let user_path = user_config_path().filter(|p| p.is_file());
    let project_path = find_project_config(cwd);
    load_from(user_path, project_path)
}

/// Read and merge explicit user/project config paths — the env-free core of
/// [`load`], so tests can exercise layering without mutating global state.
fn load_from(user_path: Option<PathBuf>, project_path: Option<PathBuf>) -> anyhow::Result<Loaded> {
    let user = read_config(user_path.as_deref())?;
    let project = read_config(project_path.as_deref())?;
    let effective = user.overlaid_with(&project);
    Ok(Loaded {
        effective,
        user,
        project,
        user_path,
        project_path,
    })
}

/// Parse a config file, or the default config if `path` is `None`.
fn read_config(path: Option<&Path>) -> anyhow::Result<Config> {
    let Some(path) = path else {
        return Ok(Config::default());
    };
    let text = std::fs::read_to_string(path)
        .map_err(|e| anyhow::anyhow!("reading config {}: {e}", path.display()))?;
    toml::from_str(&text).map_err(|e| anyhow::anyhow!("parsing config {}: {e}", path.display()))
}

/// The user config path: `$ROTEIRO_HOME/config.toml`, else `~/.roteiro/config.toml`
/// (mirrors the model store's home resolution).
fn user_config_path() -> Option<PathBuf> {
    if let Some(home) = std::env::var_os("ROTEIRO_HOME") {
        return Some(PathBuf::from(home).join("config.toml"));
    }
    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
    Some(PathBuf::from(home).join(".roteiro").join("config.toml"))
}

/// Find the project `roteiro.toml` at the **repository root** — the nearest
/// ancestor of `start` that contains a `.git` entry (per ADR-0007, the project
/// config lives alongside the git dir). Bounding discovery to the repo root
/// keeps it from ascending into parent directories *outside* the repo and stops
/// a `roteiro.toml` in a nested subdirectory from shadowing the repo-level one.
/// Returns `None` when `start` is not inside a git repository, or the root has
/// no `roteiro.toml`.
fn find_project_config(start: &Path) -> Option<PathBuf> {
    let mut dir = Some(start);
    while let Some(d) = dir {
        // `.git` is a directory in a normal clone but a file in worktrees and
        // submodules, so test existence rather than `is_dir`.
        if d.join(".git").exists() {
            let candidate = d.join("roteiro.toml");
            return candidate.is_file().then_some(candidate);
        }
        dir = d.parent();
    }
    None
}

#[cfg(test)]
mod tests {
    use super::{Config, find_project_config, load_from};

    #[test]
    fn project_config_is_repo_root_bounded() {
        let root = std::env::temp_dir().join(format!("roteiro-disc-{}", std::process::id()));
        std::fs::remove_dir_all(&root).ok();
        let repo = root.join("repo");
        let sub = repo.join("crate").join("src");
        std::fs::create_dir_all(&sub).expect("mkdir");
        std::fs::create_dir_all(repo.join(".git")).expect("mkdir .git");

        // A `roteiro.toml` above the repo (in `root`) must NOT be picked up when
        // running from inside the repo — discovery stops at the repo root.
        std::fs::write(root.join("roteiro.toml"), "[infer]\ntop_k = 1\n").expect("write outside");
        assert_eq!(
            find_project_config(&sub),
            None,
            "no repo-root config → None, never the parent-dir one"
        );

        // With a config at the repo root, running from a nested subdir finds it.
        let at_root = repo.join("roteiro.toml");
        std::fs::write(&at_root, "[infer]\ntop_k = 2\n").expect("write root");
        assert_eq!(find_project_config(&sub), Some(at_root));

        // Not inside a git repo at all → None (the outside config is ignored).
        assert_eq!(find_project_config(&root), None);

        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn config_layering_precedence_and_errors() {
        let dir = std::env::temp_dir().join(format!("roteiro-cfg-{}", std::process::id()));
        std::fs::create_dir_all(&dir).expect("mkdir");
        let user = dir.join("config.toml");
        let project = dir.join("roteiro.toml");

        // No config files → all defaults.
        let loaded = load_from(None, None).expect("load");
        assert_eq!(loaded.effective, Config::default());
        assert!(loaded.project_path.is_none());

        // User layer sets both infer knobs and an embedding model; project layer
        // overrides min_confidence only.
        std::fs::write(
            &user,
            "[infer]\nmin_confidence = 0.3\ntop_k = 9\n[models]\nembedding = \"bge-base-en-v1.5\"\n",
        )
        .expect("write user");
        std::fs::write(&project, "[infer]\nmin_confidence = 0.7\n").expect("write project");

        let loaded = load_from(Some(user.clone()), Some(project.clone())).expect("load");
        // project wins for min_confidence; user's top_k + embedding survive.
        assert_eq!(loaded.effective.infer.min_confidence, Some(0.7));
        assert_eq!(loaded.effective.infer.top_k, Some(9));
        assert_eq!(
            loaded.effective.models.embedding.as_deref(),
            Some("bge-base-en-v1.5")
        );
        assert_eq!(loaded.user.infer.min_confidence, Some(0.3));
        assert_eq!(loaded.project.infer.min_confidence, Some(0.7));

        // Unknown keys are ignored (forward-compatible).
        std::fs::write(&project, "[future]\nwhatever = true\n[infer]\ntop_k = 2\n").expect("write");
        let loaded = load_from(None, Some(project.clone())).expect("unknown keys ignored");
        assert_eq!(loaded.effective.infer.top_k, Some(2));

        // A malformed file is a hard error.
        std::fs::write(&project, "[infer]\nmin_confidence = = =\n").expect("write");
        assert!(
            load_from(None, Some(project.clone())).is_err(),
            "malformed TOML must error"
        );

        // `[pins]` parses, and merges per key (project over user).
        std::fs::write(&user, "[pins]\napp = \"v{tag}\"\nother = \"user-{tag}\"\n").expect("write");
        std::fs::write(&project, "[pins]\napp = \"release-{tag}\"\n").expect("write");
        let loaded = load_from(Some(user), Some(project)).expect("load");
        assert_eq!(
            loaded.effective.pins.get("app").map(String::as_str),
            Some("release-{tag}")
        );
        assert_eq!(
            loaded.effective.pins.get("other").map(String::as_str),
            Some("user-{tag}")
        );

        std::fs::remove_dir_all(&dir).ok();
    }
}