max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
10 files changed,
+54 insertions,
-108 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Peace of Mind | |
| 2 | 2 | ||
| 3 | - | A production operations monitor -- health checks, TLS certificate tracking, remote test orchestration, peer mesh, and email alerts. Built with Rust, Tokio, Axum, and SQLite. | |
| 3 | + | A production operations monitor: health checks, TLS certificate tracking, remote test orchestration, peer mesh, and email alerts. Built with Rust, Tokio, Axum, and SQLite. | |
| 4 | 4 | ||
| 5 | 5 | ## Prerequisites | |
| 6 | 6 | ||
| @@ -35,11 +35,11 @@ | |||
| 35 | 35 | ||
| 36 | 36 | PoM reads `~/.config/pom/pom.toml`. The config defines: | |
| 37 | 37 | ||
| 38 | - | - **Targets** -- HTTP endpoints to monitor, with expected status codes, JSON field checks, body substring matches, and check intervals | |
| 39 | - | - **Peers** -- other PoM instances in the mesh (URL, bearer token, heartbeat interval, grace period) | |
| 40 | - | - **Alerts** -- Postmark API credentials, recipient addresses, per-target cooldowns (falls back to stdout in dev mode) | |
| 41 | - | - **TLS** -- hosts to probe for certificate expiry warnings | |
| 42 | - | - **Tests** -- SSH targets and commands for remote test suite execution | |
| 38 | + | - **Targets**: HTTP endpoints to monitor, with expected status codes, JSON field checks, body substring matches, and check intervals | |
| 39 | + | - **Peers**: other PoM instances in the mesh (URL, bearer token, heartbeat interval, grace period) | |
| 40 | + | - **Alerts**: Postmark API credentials, recipient addresses, per-target cooldowns (falls back to stdout in dev mode) | |
| 41 | + | - **TLS**: hosts to probe for certificate expiry warnings | |
| 42 | + | - **Tests**: SSH targets and commands for remote test suite execution | |
| 43 | 43 | ||
| 44 | 44 | ## Module Overview | |
| 45 | 45 |
| @@ -63,7 +63,7 @@ | |||
| 63 | 63 | # Polls /admin/uploads/health.json for queue depth, stuck-scan count, | |
| 64 | 64 | # held backlog, and per-layer error rates. Thresholds per | |
| 65 | 65 | # scan-pipeline-audit.md § 6. Localhost on the makenotwork port to | |
| 66 | - | # skip the Caddy + Cloudflare path — internal-only signal. | |
| 66 | + | # skip the Caddy + Cloudflare path (internal-only signal). | |
| 67 | 67 | base_url = "http://127.0.0.1:3000" | |
| 68 | 68 | interval_secs = 300 | |
| 69 | 69 | timeout_secs = 10 |
| @@ -38,7 +38,7 @@ | |||
| 38 | 38 | <head> | |
| 39 | 39 | <meta charset="utf-8"> | |
| 40 | 40 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 41 | - | <title>PoM — {instance_name}</title> | |
| 41 | + | <title>PoM: {instance_name}</title> | |
| 42 | 42 | <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| 43 | 43 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| 44 | 44 | <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Lato:wght@400;700&display=swap" rel="stylesheet"> | |
| @@ -371,7 +371,6 @@ | |||
| 371 | 371 | } | |
| 372 | 372 | document.getElementById('global-dot').className = 'summary-dot ' + dotClass(worst).replace('dot-', 'dot-'); | |
| 373 | 373 | ||
| 374 | - | // Sort target names | |
| 375 | 374 | const names = Object.keys(targets).sort(); | |
| 376 | 375 | let gridHtml = ''; | |
| 377 | 376 | for (let i = 0; i < names.length; i++) { | |
| @@ -379,13 +378,10 @@ | |||
| 379 | 378 | } | |
| 380 | 379 | document.getElementById('target-grid').innerHTML = gridHtml; | |
| 381 | 380 | ||
| 382 | - | // Details | |
| 383 | 381 | document.getElementById('details-section').innerHTML = renderDetails(targets); | |
| 384 | 382 | ||
| 385 | - | // Update timestamp | |
| 386 | 383 | document.getElementById('last-updated').textContent = 'Updated ' + new Date().toLocaleTimeString(); | |
| 387 | 384 | ||
| 388 | - | // Mesh | |
| 389 | 385 | if (HAS_MESH) { | |
| 390 | 386 | try { | |
| 391 | 387 | const mr = await fetch('/api/mesh', FETCH_OPTS); |
| @@ -26,14 +26,7 @@ | |||
| 26 | 26 | /// Format a single health snapshot as a human-readable line. | |
| 27 | 27 | pub fn format_health_snapshot(s: &HealthSnapshot) -> String { | |
| 28 | 28 | let mut out = String::new(); | |
| 29 | - | write!( | |
| 30 | - | out, | |
| 31 | - | "[{}] {} \u{2014} {}", | |
| 32 | - | s.status.icon(), | |
| 33 | - | s.target, | |
| 34 | - | s.status | |
| 35 | - | ) | |
| 36 | - | .unwrap(); | |
| 29 | + | write!(out, "[{}] {}: {}", s.status.icon(), s.target, s.status).unwrap(); | |
| 37 | 30 | write!(out, " ({}ms)", s.response_time_ms).unwrap(); | |
| 38 | 31 | if let Some(details) = &s.details { | |
| 39 | 32 | if let Some(v) = &details.version { | |
| @@ -122,25 +115,25 @@ | |||
| 122 | 115 | ||
| 123 | 116 | if let Some(t) = tls { | |
| 124 | 117 | if let Some(ref err) = t.error { | |
| 125 | - | writeln!(out, " TLS: [ERR] {} \u{2014} {}", t.host, scrub(err)).unwrap(); | |
| 118 | + | writeln!(out, " TLS: [ERR] {}: {}", t.host, scrub(err)).unwrap(); | |
| 126 | 119 | } else if t.days_remaining <= 0 { | |
| 127 | 120 | writeln!( | |
| 128 | 121 | out, | |
| 129 | - | " TLS: [ERR] {} \u{2014} EXPIRED (expired {})", | |
| 122 | + | " TLS: [ERR] {}: EXPIRED (expired {})", | |
| 130 | 123 | t.host, t.not_after | |
| 131 | 124 | ) | |
| 132 | 125 | .unwrap(); | |
| 133 | 126 | } else if t.days_remaining <= 14 { | |
| 134 | 127 | writeln!( | |
| 135 | 128 | out, | |
| 136 | - | " TLS: [WARN] {} \u{2014} {}d remaining (expires {})", | |
| 129 | + | " TLS: [WARN] {}: {}d remaining (expires {})", | |
| 137 | 130 | t.host, t.days_remaining, t.not_after | |
| 138 | 131 | ) | |
| 139 | 132 | .unwrap(); | |
| 140 | 133 | } else { | |
| 141 | 134 | writeln!( | |
| 142 | 135 | out, | |
| 143 | - | " TLS: [OK] {} \u{2014} {}d remaining (expires {})", | |
| 136 | + | " TLS: [OK] {}: {}d remaining (expires {})", | |
| 144 | 137 | t.host, t.days_remaining, t.not_after | |
| 145 | 138 | ) | |
| 146 | 139 | .unwrap(); | |
| @@ -193,24 +186,14 @@ | |||
| 193 | 186 | ||
| 194 | 187 | if let Some(w) = whois { | |
| 195 | 188 | if let Some(ref err) = w.error { | |
| 196 | - | writeln!(out, " WHOIS: [ERR] {} \u{2014} {}", w.domain, scrub(err)).unwrap(); | |
| 189 | + | writeln!(out, " WHOIS: [ERR] {}: {}", w.domain, scrub(err)).unwrap(); | |
| 197 | 190 | } else if let Some(days) = w.days_remaining { | |
| 198 | 191 | if days <= 0 { | |
| 199 | - | writeln!(out, " WHOIS: [ERR] {} \u{2014} EXPIRED", w.domain).unwrap(); | |
| 192 | + | writeln!(out, " WHOIS: [ERR] {}: EXPIRED", w.domain).unwrap(); | |
| 200 | 193 | } else if days <= 30 { | |
| 201 | - | writeln!( | |
| 202 | - | out, | |
| 203 | - | " WHOIS: [WARN] {} \u{2014} {}d remaining", | |
| 204 | - | w.domain, days | |
| 205 | - | ) | |
| 206 | - | .unwrap(); | |
| 194 | + | writeln!(out, " WHOIS: [WARN] {}: {}d remaining", w.domain, days).unwrap(); | |
| 207 | 195 | } else { | |
| 208 | - | writeln!( | |
| 209 | - | out, | |
| 210 | - | " WHOIS: [OK] {} \u{2014} {}d remaining", | |
| 211 | - | w.domain, days | |
| 212 | - | ) | |
| 213 | - | .unwrap(); | |
| 196 | + | writeln!(out, " WHOIS: [OK] {}: {}d remaining", w.domain, days).unwrap(); | |
| 214 | 197 | } | |
| 215 | 198 | } | |
| 216 | 199 | } | |
| @@ -233,7 +216,7 @@ | |||
| 233 | 216 | && s.stale | |
| 234 | 217 | && let Some(reason) = &s.reason | |
| 235 | 218 | { | |
| 236 | - | writeln!(out, " Tests: STALE \u{2014} {}", scrub(reason)).unwrap(); | |
| 219 | + | writeln!(out, " Tests: STALE ({})", scrub(reason)).unwrap(); | |
| 237 | 220 | } | |
| 238 | 221 | ||
| 239 | 222 | if let Some(inc) = incident { | |
| @@ -258,7 +241,7 @@ | |||
| 258 | 241 | for h in history { | |
| 259 | 242 | writeln!( | |
| 260 | 243 | out, | |
| 261 | - | "[{}] {} \u{2014} {} ({}ms) {}", | |
| 244 | + | "[{}] {}: {} ({}ms) {}", | |
| 262 | 245 | h.status.icon(), | |
| 263 | 246 | h.target, | |
| 264 | 247 | h.status, | |
| @@ -284,7 +267,7 @@ | |||
| 284 | 267 | } | |
| 285 | 268 | write!(out, " {}", r.started_at).unwrap(); | |
| 286 | 269 | if let (Some(p), Some(f)) = (r.summary.total_passed, r.summary.total_failed) { | |
| 287 | - | write!(out, " \u{2014} {p} passed, {f} failed").unwrap(); | |
| 270 | + | write!(out, " ({p} passed, {f} failed)").unwrap(); | |
| 288 | 271 | } | |
| 289 | 272 | writeln!(out).unwrap(); | |
| 290 | 273 | } | |
| @@ -323,25 +306,13 @@ | |||
| 323 | 306 | writeln!(out, "DNS Records:").unwrap(); | |
| 324 | 307 | for r in dns_results { | |
| 325 | 308 | if let Some(ref err) = r.error { | |
| 326 | - | writeln!( | |
| 327 | - | out, | |
| 328 | - | " [ERR] {} {} \u{2014} {}", | |
| 329 | - | r.name, | |
| 330 | - | r.record_type, | |
| 331 | - | scrub(err) | |
| 332 | - | ) | |
| 333 | - | .unwrap(); | |
| 309 | + | writeln!(out, " [ERR] {} {}: {}", r.name, r.record_type, scrub(err)).unwrap(); | |
| 334 | 310 | } else if r.matches { | |
| 335 | - | writeln!( | |
| 336 | - | out, | |
| 337 | - | " [OK] {} {} \u{2014} {:?}", | |
| 338 | - | r.name, r.record_type, r.actual | |
| 339 | - | ) | |
| 340 | - | .unwrap(); | |
| 311 | + | writeln!(out, " [OK] {} {}: {:?}", r.name, r.record_type, r.actual).unwrap(); | |
| 341 | 312 | } else { | |
| 342 | 313 | writeln!( | |
| 343 | 314 | out, | |
| 344 | - | " [FAIL] {} {} \u{2014} expected {:?}, got {:?}", | |
| 315 | + | " [FAIL] {} {}: expected {:?}, got {:?}", | |
| 345 | 316 | r.name, r.record_type, r.expected, r.actual | |
| 346 | 317 | ) | |
| 347 | 318 | .unwrap(); | |
| @@ -356,19 +327,14 @@ | |||
| 356 | 327 | writeln!(out, "WHOIS:").unwrap(); | |
| 357 | 328 | for w in whois_results { | |
| 358 | 329 | if let Some(ref err) = w.error { | |
| 359 | - | writeln!(out, " [ERR] {} \u{2014} {}", w.domain, scrub(err)).unwrap(); | |
| 330 | + | writeln!(out, " [ERR] {}: {}", w.domain, scrub(err)).unwrap(); | |
| 360 | 331 | } else { | |
| 361 | 332 | let days_str = w.days_remaining.map_or_else( | |
| 362 | 333 | || "expiry unknown".to_string(), | |
| 363 | 334 | |d| format!("{d}d remaining"), | |
| 364 | 335 | ); | |
| 365 | 336 | let registrar_str = w.registrar.as_deref().unwrap_or("unknown registrar"); | |
| 366 | - | writeln!( | |
| 367 | - | out, | |
| 368 | - | " [OK] {} \u{2014} {days_str} ({registrar_str})", | |
| 369 | - | w.domain | |
| 370 | - | ) | |
| 371 | - | .unwrap(); | |
| 337 | + | writeln!(out, " [OK] {}: {days_str} ({registrar_str})", w.domain).unwrap(); | |
| 372 | 338 | } | |
| 373 | 339 | } | |
| 374 | 340 | } | |
| @@ -484,7 +450,7 @@ | |||
| 484 | 450 | assert!(!cleaned.contains('\u{1b}'), "ESC must be stripped"); | |
| 485 | 451 | assert!(!cleaned.contains('\r') && !cleaned.contains('\n')); | |
| 486 | 452 | assert_eq!(cleaned, "1.0[2J[1;1H FAKE ALL-CLEAR"); | |
| 487 | - | // Printable Unicode (e.g. the em-dash the display uses) is preserved. | |
| 453 | + | // Printable non-ASCII Unicode is preserved. | |
| 488 | 454 | assert_eq!(scrub("v1.2 \u{2014} ok"), "v1.2 \u{2014} ok"); | |
| 489 | 455 | } | |
| 490 | 456 |
| @@ -752,7 +752,6 @@ | |||
| 752 | 752 | // First alert, not in cooldown | |
| 753 | 753 | assert!(!alerter.is_within_cooldown("health:mnw").await); | |
| 754 | 754 | ||
| 755 | - | // Record an alert | |
| 756 | 755 | db::insert_alert( | |
| 757 | 756 | &pool, | |
| 758 | 757 | "health:mnw", | |
| @@ -764,7 +763,6 @@ | |||
| 764 | 763 | .await | |
| 765 | 764 | .unwrap(); | |
| 766 | 765 | ||
| 767 | - | // Now should be in cooldown | |
| 768 | 766 | assert!(alerter.is_within_cooldown("health:mnw").await); | |
| 769 | 767 | } | |
| 770 | 768 | ||
| @@ -979,12 +977,10 @@ | |||
| 979 | 977 | // Not in cooldown initially | |
| 980 | 978 | assert!(!alerter.is_within_cooldown("health:example.com").await); | |
| 981 | 979 | ||
| 982 | - | // Send an alert for "example.com" | |
| 983 | 980 | alerter | |
| 984 | 981 | .send_health_alert("example.com", "Example", "operational", "error", None) | |
| 985 | 982 | .await; | |
| 986 | 983 | ||
| 987 | - | // Same target should now be in cooldown | |
| 988 | 984 | assert!(alerter.is_within_cooldown("health:example.com").await); | |
| 989 | 985 | ||
| 990 | 986 | // Different target should NOT be in cooldown |
| @@ -20,7 +20,6 @@ | |||
| 20 | 20 | // Cancellation token for graceful shutdown | |
| 21 | 21 | let token = tokio_util::sync::CancellationToken::new(); | |
| 22 | 22 | ||
| 23 | - | // Instance identity | |
| 24 | 23 | let instance_id = peer::load_or_create_instance_id(config.instance.id.as_deref())?; | |
| 25 | 24 | let instance_name = config.instance_name(); | |
| 26 | 25 | let instance_info = peer::InstanceInfo { | |
| @@ -31,7 +30,6 @@ | |||
| 31 | 30 | started_at: chrono::Utc::now().to_rfc3339(), | |
| 32 | 31 | }; | |
| 33 | 32 | ||
| 34 | - | // Alerter | |
| 35 | 33 | let alerter = match config.alerts.as_ref() { | |
| 36 | 34 | Some(alert_config) => { | |
| 37 | 35 | info!("Alerts enabled (to: {})", alert_config.to); | |
| @@ -49,7 +47,6 @@ | |||
| 49 | 47 | info!("Instance: {instance_name} (id={instance_id})"); | |
| 50 | 48 | info!("Starting serve mode (default interval: {default_interval}s, prune: {prune_days}d)"); | |
| 51 | 49 | ||
| 52 | - | // Mesh state | |
| 53 | 50 | let mesh = peer::new_mesh_state(instance_info, &config.peers); | |
| 54 | 51 | ||
| 55 | 52 | // Load known peer identities from DB | |
| @@ -64,7 +61,6 @@ | |||
| 64 | 61 | ||
| 65 | 62 | let mut handles: Vec<JoinHandle<()>> = Vec::new(); | |
| 66 | 63 | ||
| 67 | - | // Spawn all monitoring tasks | |
| 68 | 64 | handles.extend(tasks::spawn_health_tasks( | |
| 69 | 65 | config, | |
| 70 | 66 | pool, | |
| @@ -139,7 +135,6 @@ | |||
| 139 | 135 | handles.extend(hb_handles); | |
| 140 | 136 | } | |
| 141 | 137 | ||
| 142 | - | // Spawn monitoring-offline meta-alert task | |
| 143 | 138 | if let Some(handle) = | |
| 144 | 139 | tasks::spawn_meta_alert_task(config, pool, default_interval, &token, alerter.as_ref()) | |
| 145 | 140 | { |
| @@ -152,7 +152,7 @@ | |||
| 152 | 152 | if staleness.stale | |
| 153 | 153 | && let Some(reason) = &staleness.reason | |
| 154 | 154 | { | |
| 155 | - | let _ = writeln!(target_status, "Tests: STALE \u{2014} {reason}"); | |
| 155 | + | let _ = writeln!(target_status, "Tests: STALE ({reason})"); | |
| 156 | 156 | } | |
| 157 | 157 | } | |
| 158 | 158 |
| @@ -57,7 +57,6 @@ | |||
| 57 | 57 | } | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | - | /// Get health check history. | |
| 61 | 60 | #[tool( | |
| 62 | 61 | description = "Get recent health check history. Optionally filter by target and limit results (default 10)." | |
| 63 | 62 | )] | |
| @@ -71,7 +70,6 @@ | |||
| 71 | 70 | } | |
| 72 | 71 | } | |
| 73 | 72 | ||
| 74 | - | /// List configured targets. | |
| 75 | 73 | #[tool( | |
| 76 | 74 | description = "List all configured targets with their labels and capabilities (health check, test suite)." | |
| 77 | 75 | )] | |
| @@ -93,7 +91,6 @@ | |||
| 93 | 91 | } | |
| 94 | 92 | } | |
| 95 | 93 | ||
| 96 | - | /// Get test run history. | |
| 97 | 94 | #[tool( | |
| 98 | 95 | description = "Get recent test run history (without raw output). Optionally filter by target and limit results (default 10)." | |
| 99 | 96 | )] | |
| @@ -121,7 +118,6 @@ | |||
| 121 | 118 | } | |
| 122 | 119 | } | |
| 123 | 120 | ||
| 124 | - | /// Get peer mesh status. | |
| 125 | 121 | #[tool( | |
| 126 | 122 | description = "Get the peer mesh status showing all PoM instances, their connectivity, versions, and target health. Requires serve mode to be running." | |
| 127 | 123 | )] |
| @@ -1,8 +1,8 @@ | |||
| 1 | - | # TagTree -- Architecture | |
| 1 | + | # TagTree Architecture | |
| 2 | 2 | ||
| 3 | 3 | ## Purpose | |
| 4 | 4 | ||
| 5 | - | TagTree is a shared Rust library crate that provides a hierarchical dot-notation tag standard. Tags are lowercase strings with dot-separated segments representing a hierarchy (e.g., `genre.electronic.house`, `work.meeting.standup`). The crate handles validation, parsing, tree operations, SQL helpers, prefix renaming, and keystroke-speed autocomplete -- with zero runtime dependencies. | |
| 5 | + | TagTree is a shared Rust library crate that provides a hierarchical dot-notation tag standard. Tags are lowercase strings with dot-separated segments representing a hierarchy (e.g., `genre.electronic.house`, `work.meeting.standup`). The crate handles validation, parsing, tree operations, SQL helpers, prefix renaming, and keystroke-speed autocomplete, with zero runtime dependencies. | |
| 6 | 6 | ||
| 7 | 7 | ## Tag Format | |
| 8 | 8 | ||
| @@ -22,9 +22,9 @@ | |||
| 22 | 22 | | MT | 3 | 64 | 0 | Community-scoped thread tags | | |
| 23 | 23 | | MNW | 5 | 100 | 0 | Content/project tags | | |
| 24 | 24 | ||
| 25 | - | - `max_depth` -- maximum number of segments allowed | |
| 26 | - | - `max_length` -- maximum character length of the entire tag string | |
| 27 | - | - `semantic_depth` -- number of leading segments that carry dispatch meaning (0 = all free-form; 1 = first segment is a namespace like `genre`, `mood`) | |
| 25 | + | - `max_depth`: maximum number of segments allowed | |
| 26 | + | - `max_length`: maximum character length of the entire tag string | |
| 27 | + | - `semantic_depth`: number of leading segments that carry dispatch meaning (0 = all free-form; 1 = first segment is a namespace like `genre`, `mood`) | |
| 28 | 28 | ||
| 29 | 29 | ## Core Types | |
| 30 | 30 | ||
| @@ -39,45 +39,45 @@ | |||
| 39 | 39 | ||
| 40 | 40 | ### Validation | |
| 41 | 41 | ||
| 42 | - | - `validate_with(tag, config)` -- validate against a `TagConfig` | |
| 43 | - | - `validate(tag)` -- validate with defaults (depth 5, length 100, no semantic prefix) | |
| 42 | + | - `validate_with(tag, config)`: validate against a `TagConfig` | |
| 43 | + | - `validate(tag)`: validate with defaults (depth 5, length 100, no semantic prefix) | |
| 44 | 44 | ||
| 45 | 45 | ### Hierarchy Parsing | |
| 46 | 46 | ||
| 47 | - | - `parent(tag)` -- parent path (`"a.b.c"` -> `Some("a.b")`) | |
| 48 | - | - `leaf(tag)` -- last segment (`"a.b.c"` -> `"c"`) | |
| 49 | - | - `depth(tag)` -- segment count (`"a.b.c"` -> `3`) | |
| 50 | - | - `segment(tag, i)` -- extract segment by 0-based index | |
| 51 | - | - `prefix_at_depth(tag, n)` -- first `n` segments as a path | |
| 52 | - | - `ancestors(tag)` -- all ancestor paths, root to parent | |
| 47 | + | - `parent(tag)`: parent path (`"a.b.c"` -> `Some("a.b")`) | |
| 48 | + | - `leaf(tag)`: last segment (`"a.b.c"` -> `"c"`) | |
| 49 | + | - `depth(tag)`: segment count (`"a.b.c"` -> `3`) | |
| 50 | + | - `segment(tag, i)`: extract segment by 0-based index | |
| 51 | + | - `prefix_at_depth(tag, n)`: first `n` segments as a path | |
| 52 | + | - `ancestors(tag)`: all ancestor paths, root to parent | |
| 53 | 53 | ||
| 54 | 54 | ### Tree Relationships | |
| 55 | 55 | ||
| 56 | - | - `is_ancestor_of(a, b)` -- true if `a` is an ancestor of `b` at a segment boundary | |
| 57 | - | - `common_ancestor(a, b)` -- longest shared prefix | |
| 56 | + | - `is_ancestor_of(a, b)`: true if `a` is an ancestor of `b` at a segment boundary | |
| 57 | + | - `common_ancestor(a, b)`: longest shared prefix | |
| 58 | 58 | ||
| 59 | 59 | ### Set Operations (in-memory) | |
| 60 | 60 | ||
| 61 | - | - `children_at_prefix(prefix, tags)` -- immediate children one level below prefix | |
| 62 | - | - `subtree(prefix, tags)` -- all descendants of prefix (including prefix if present) | |
| 63 | - | - `rename_prefix(old, new, tag)` -- swap a tag's prefix, returning new tag | |
| 61 | + | - `children_at_prefix(prefix, tags)`: immediate children one level below prefix | |
| 62 | + | - `subtree(prefix, tags)`: all descendants of prefix (including prefix if present) | |
| 63 | + | - `rename_prefix(old, new, tag)`: swap a tag's prefix, returning new tag | |
| 64 | 64 | ||
| 65 | 65 | ### Semantic Splitting | |
| 66 | 66 | ||
| 67 | - | - `semantic_prefix(tag, depth)` -- namespace portion | |
| 68 | - | - `free_suffix(tag, depth)` -- value portion after semantic prefix | |
| 67 | + | - `semantic_prefix(tag, depth)`: namespace portion | |
| 68 | + | - `free_suffix(tag, depth)`: value portion after semantic prefix | |
| 69 | 69 | ||
| 70 | 70 | ### SQL Helpers | |
| 71 | 71 | ||
| 72 | - | - `escape_like(s)` -- escape `%`, `_`, `\` for safe LIKE embedding | |
| 73 | - | - `like_descendant_pattern(prefix)` -- build `prefix.%` for hierarchy queries (works on both SQLite and PostgreSQL) | |
| 72 | + | - `escape_like(s)`: escape `%`, `_`, `\` for safe LIKE embedding | |
| 73 | + | - `like_descendant_pattern(prefix)`: build `prefix.%` for hierarchy queries (works on both SQLite and PostgreSQL) | |
| 74 | 74 | ||
| 75 | 75 | ### TagIndex (Autocomplete) | |
| 76 | 76 | ||
| 77 | 77 | Sorted `Vec<String>` with binary-search lookup. Two-tier suggestion strategy: | |
| 78 | 78 | ||
| 79 | - | 1. **Path prefix** (tier 1) -- binary search for tags whose full path starts with input. O(log n + k). | |
| 80 | - | 2. **Segment prefix** (tier 2) -- linear scan for tags where any non-first segment starts with input. Only runs if tier 1 didn't fill the limit and input has no dots. A segment index gates entry: if no known segment starts with input, scan is skipped in O(log m). | |
| 79 | + | 1. **Path prefix** (tier 1): binary search for tags whose full path starts with input. O(log n + k). | |
| 80 | + | 2. **Segment prefix** (tier 2): linear scan for tags where any non-first segment starts with input. Only runs if tier 1 didn't fill the limit and input has no dots. A segment index gates entry: if no known segment starts with input, scan is skipped in O(log m). | |
| 81 | 81 | ||
| 82 | 82 | Key methods: `new()`, `empty()`, `insert()`, `remove()`, `rebuild()`, `contains()`, `suggest(input, limit)`, `suggest_with_status(input, limit)`. | |
| 83 | 83 | ||
| @@ -98,9 +98,9 @@ | |||
| 98 | 98 | // WHERE tag LIKE ?1 ESCAPE '\' -> finds all genre.* tags | |
| 99 | 99 | ``` | |
| 100 | 100 | ||
| 101 | - | - **AF, GO, BB** -- SQLite repositories use `like_descendant_pattern` for hierarchy queries; `TagIndex` powers autocomplete in the UI | |
| 102 | - | - **MT** -- community-scoped tags validated per-thread; migration 014 added `path` column using TagTree conventions | |
| 103 | - | - **MNW** -- server-side validation on content/project tags; migration 038 added `path` column | |
| 101 | + | - **AF, GO, BB**: SQLite repositories use `like_descendant_pattern` for hierarchy queries; `TagIndex` powers autocomplete in the UI | |
| 102 | + | - **MT**: community-scoped tags validated per-thread; migration 014 added `path` column using TagTree conventions | |
| 103 | + | - **MNW**: server-side validation on content/project tags; migration 038 added `path` column | |
| 104 | 104 | ||
| 105 | 105 | ## Performance Characteristics | |
| 106 | 106 |
| @@ -793,7 +793,6 @@ | |||
| 793 | 793 | /// Insert a tag into the index. Maintains sorted order. Idempotent. | |
| 794 | 794 | pub fn insert(&mut self, tag: String) { | |
| 795 | 795 | if let Err(pos) = self.tags.binary_search(&tag) { | |
| 796 | - | // Add new segments | |
| 797 | 796 | for seg in tag.split(SEPARATOR) { | |
| 798 | 797 | let s = seg.to_string(); | |
| 799 | 798 | if let Err(sp) = self.segments.binary_search(&s) { | |
| @@ -980,8 +979,6 @@ | |||
| 980 | 979 | } | |
| 981 | 980 | } | |
| 982 | 981 | ||
| 983 | - | // Tests | |
| 984 | - | ||
| 985 | 982 | #[cfg(test)] | |
| 986 | 983 | mod tests { | |
| 987 | 984 | use super::*; |