1pub mod adapters;
2pub mod composer;
3pub mod config;
4pub mod extract;
5pub mod meili;
6pub mod models;
7pub mod projects;
8pub mod query;
9pub mod sanitizer;
10pub mod scanner;
11pub mod tests_linker;
12
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use anyhow::{Context, Result, bail};
17use chrono::Utc;
18use clap::{Parser, Subcommand, ValueEnum};
19
20use crate::config::{IndexerConfig, default_connect_file_path};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, serde::Serialize, serde::Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum Framework {
25 Auto,
26 Laravel,
27 Hyperf,
28}
29
30impl Framework {
31 pub fn as_str(self) -> &'static str {
32 match self {
33 Self::Auto => "auto",
34 Self::Laravel => "laravel",
35 Self::Hyperf => "hyperf",
36 }
37 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, serde::Serialize, serde::Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum IndexMode {
43 Clean,
44 Staged,
45}
46
47impl IndexMode {
48 pub fn as_str(self) -> &'static str {
49 match self {
50 Self::Clean => "clean",
51 Self::Staged => "staged",
52 }
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
57pub enum SearchIndex {
58 All,
59 Symbols,
60 Routes,
61 Tests,
62 Packages,
63 Schema,
64}
65
66impl SearchIndex {
67 pub fn suffix(self) -> &'static str {
68 match self {
69 Self::All => "all",
70 Self::Symbols => "symbols",
71 Self::Routes => "routes",
72 Self::Tests => "tests",
73 Self::Packages => "packages",
74 Self::Schema => "schema",
75 }
76 }
77}
78
79#[derive(Debug, Parser)]
80#[command(
81 name = "source-map-php",
82 version,
83 about = "CLI-first PHP code search indexer"
84)]
85pub struct Cli {
86 #[command(subcommand)]
87 command: Commands,
88}
89
90#[derive(Debug, Subcommand)]
91enum Commands {
92 Init {
93 #[arg(long, default_value = ".")]
94 dir: PathBuf,
95 #[arg(long)]
96 force: bool,
97 },
98 Doctor {
99 #[arg(long, default_value = ".")]
100 repo: PathBuf,
101 #[arg(long, default_value = "config/indexer.toml")]
102 config: PathBuf,
103 },
104 Index {
105 #[arg(long)]
106 repo: PathBuf,
107 #[arg(long)]
108 project_name: Option<String>,
109 #[arg(long, value_enum, default_value_t = Framework::Auto)]
110 framework: Framework,
111 #[arg(long, value_enum, default_value_t = IndexMode::Clean)]
112 mode: IndexMode,
113 #[arg(long, default_value = "config/indexer.toml")]
114 config: PathBuf,
115 },
116 Search {
117 #[arg(long)]
118 query: String,
119 #[arg(long, value_enum, default_value_t = SearchIndex::All)]
120 index: SearchIndex,
121 #[arg(long)]
122 project: Option<String>,
123 #[arg(long)]
124 framework: Option<Framework>,
125 #[arg(long, default_value = "config/indexer.toml")]
126 config: PathBuf,
127 #[arg(long)]
128 json: bool,
129 },
130 Remove {
131 #[arg(long)]
132 project: String,
133 #[arg(long)]
134 keep_indexes: bool,
135 #[arg(long, default_value = "config/indexer.toml")]
136 config: PathBuf,
137 },
138 Validate {
139 #[arg(long)]
140 symbol: String,
141 #[arg(long, default_value = "config/indexer.toml")]
142 config: PathBuf,
143 #[arg(long)]
144 json: bool,
145 },
146 Verify {
147 #[arg(long, default_value = "config/indexer.toml")]
148 config: PathBuf,
149 },
150 Promote {
151 #[arg(long, default_value = "config/indexer.toml")]
152 config: PathBuf,
153 #[arg(long)]
154 run_id: Option<String>,
155 },
156}
157
158pub fn run() -> Result<()> {
159 let cli = Cli::parse();
160 match cli.command {
161 Commands::Init { dir, force } => init_workspace(&dir, force),
162 Commands::Doctor { repo, config } => commands::doctor(&repo, &config),
163 Commands::Index {
164 repo,
165 project_name,
166 framework,
167 mode,
168 config,
169 } => commands::index(&repo, project_name.as_deref(), framework, mode, &config),
170 Commands::Search {
171 query,
172 index,
173 project,
174 framework,
175 config,
176 json,
177 } => commands::search(&query, project.as_deref(), index, framework, &config, json),
178 Commands::Remove {
179 project,
180 keep_indexes,
181 config,
182 } => commands::remove(&project, keep_indexes, &config),
183 Commands::Validate {
184 symbol,
185 config,
186 json,
187 } => commands::validate(&symbol, &config, json),
188 Commands::Verify { config } => commands::verify(&config),
189 Commands::Promote { config, run_id } => commands::promote(&config, run_id.as_deref()),
190 }
191}
192
193fn init_workspace(dir: &Path, force: bool) -> Result<()> {
194 init_workspace_with_connect_path(dir, &default_connect_file_path(), force)
195}
196
197fn init_workspace_with_connect_path(dir: &Path, connect_path: &Path, force: bool) -> Result<()> {
198 let config_dir = dir.join("config");
199 fs::create_dir_all(&config_dir).with_context(|| format!("create {}", config_dir.display()))?;
200
201 write_scaffold(
202 &config_dir.join("indexer.toml"),
203 &IndexerConfig::default().to_toml_string()?,
204 force,
205 )?;
206 write_scaffold(&dir.join(".env.example"), assets::env_example(), force)?;
207 write_scaffold(
208 &dir.join("docker-compose.meilisearch.yml"),
209 assets::docker_compose_example(),
210 force,
211 )?;
212 let global_template_created =
213 write_scaffold_if_missing(connect_path, assets::meili_connect_template())?;
214
215 println!(
216 "Initialized source-map-php config in {} at {}",
217 dir.display(),
218 Utc::now().to_rfc3339()
219 );
220 if global_template_created {
221 println!(
222 "Created Meilisearch connect template at {}",
223 connect_path.display()
224 );
225 } else {
226 println!(
227 "Left existing Meilisearch connect file unchanged at {}",
228 connect_path.display()
229 );
230 }
231 Ok(())
232}
233
234fn write_scaffold(path: &Path, content: &str, force: bool) -> Result<()> {
235 if path.exists() && !force {
236 bail!(
237 "{} already exists, rerun with --force to overwrite",
238 path.display()
239 );
240 }
241 if let Some(parent) = path.parent() {
242 fs::create_dir_all(parent)
243 .with_context(|| format!("create parent directory for {}", path.display()))?;
244 }
245 fs::write(path, content).with_context(|| format!("write {}", path.display()))?;
246 Ok(())
247}
248
249fn write_scaffold_if_missing(path: &Path, content: &str) -> Result<bool> {
250 if path.exists() {
251 return Ok(false);
252 }
253 if let Some(parent) = path.parent() {
254 fs::create_dir_all(parent)
255 .with_context(|| format!("create parent directory for {}", path.display()))?;
256 }
257 fs::write(path, content).with_context(|| format!("write {}", path.display()))?;
258 Ok(true)
259}
260
261mod assets {
262 pub fn env_example() -> &'static str {
263 "MEILI_HOST=http://127.0.0.1:7700\nMEILI_MASTER_KEY=change-me\n"
264 }
265
266 pub fn docker_compose_example() -> &'static str {
267 "services:\n meilisearch:\n image: getmeili/meilisearch:v1.12\n ports:\n - \"7700:7700\"\n environment:\n MEILI_ENV: production\n MEILI_MASTER_KEY: \"${MEILI_MASTER_KEY}\"\n MEILI_NO_ANALYTICS: \"true\"\n volumes:\n - meili_data:/meili_data\n restart: unless-stopped\n\nvolumes:\n meili_data:\n"
268 }
269
270 pub fn meili_connect_template() -> &'static str {
271 "{\n \"url\": \"http://127.0.0.1:7700\",\n \"apiKey\": \"change-me\"\n}\n"
272 }
273}
274
275pub mod commands {
276 use std::collections::HashMap;
277 use std::env;
278 use std::fs;
279 use std::path::{Path, PathBuf};
280 use std::process::Command;
281
282 use anyhow::{Context, Result, anyhow, bail};
283 use chrono::Utc;
284 use serde_json::json;
285 use sha1::{Digest, Sha1};
286
287 use crate::adapters;
288 use crate::composer::{ComposerExport, export_packages};
289 use crate::config::IndexerConfig;
290 use crate::extract::extract_symbols;
291 use crate::meili::{
292 MeiliClient, packages_settings, routes_settings, runs_settings, schema_settings,
293 symbols_settings, tests_settings,
294 };
295 use crate::models::{
296 PackageDoc, RouteDoc, RunManifest, SchemaDoc, SymbolDoc, TestDoc, make_stable_id,
297 manifest_path, run_id,
298 };
299 use crate::projects::{ProjectRecord, ProjectRegistry, default_project_registry_path};
300 use crate::query::compact_query;
301 use crate::sanitizer::Sanitizer;
302 use crate::scanner::scan_repo;
303 use crate::tests_linker::{extract_tests, link_symbols_and_routes};
304 use crate::{Framework, IndexMode, SearchIndex};
305
306 #[derive(Debug, serde::Serialize, serde::Deserialize)]
307 struct SymbolSearchDoc {
308 fqn: String,
309 path: String,
310 line_start: usize,
311 package_name: String,
312 #[serde(default)]
313 related_tests: Vec<String>,
314 #[serde(default)]
315 missing_test_warning: Option<String>,
316 }
317
318 #[derive(Debug, serde::Serialize, serde::Deserialize)]
319 struct RouteSearchDoc {
320 method: String,
321 uri: String,
322 action: Option<String>,
323 }
324
325 #[derive(Debug, serde::Serialize, serde::Deserialize)]
326 struct TestSearchDoc {
327 fqn: String,
328 command: String,
329 }
330
331 #[derive(Debug, serde::Serialize, serde::Deserialize)]
332 struct PackageSearchDoc {
333 name: String,
334 version: Option<String>,
335 }
336
337 #[derive(Debug, serde::Serialize, serde::Deserialize)]
338 struct SchemaSearchDoc {
339 operation: String,
340 table: Option<String>,
341 path: String,
342 line_start: usize,
343 }
344
345 pub fn doctor(repo: &Path, config: &Path) -> Result<()> {
346 let repo = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
347 load_env_for(config);
348 let config = IndexerConfig::load(config)?;
349
350 let checks = vec![
351 ("php", command_exists("php"), true),
352 ("composer", command_exists("composer"), true),
353 ("phpactor", command_exists("phpactor"), false),
354 ("git", command_exists("git"), true),
355 ];
356 for (name, ok, _) in &checks {
357 println!("{name:10} {}", if *ok { "ok" } else { "missing" });
358 }
359
360 let packages = export_packages(&repo).ok();
361 let framework = packages
362 .as_ref()
363 .map(|packages| {
364 adapters::detect_framework(
365 &repo,
366 Framework::Auto,
367 &packages
368 .packages
369 .iter()
370 .map(|package| package.name.clone())
371 .collect::<Vec<_>>(),
372 )
373 })
374 .unwrap_or(Framework::Auto);
375 println!("framework {}", framework.as_str());
376
377 match config.resolve_meili() {
378 Ok(connection) => {
379 let client = MeiliClient::new(connection)?;
380 let health = client.health()?;
381 println!("meilisearch ok {health}");
382 }
383 Err(err) => {
384 println!("meilisearch missing {err}");
385 }
386 }
387
388 if framework == Framework::Laravel {
389 let ok = Command::new("php")
390 .arg("artisan")
391 .arg("--version")
392 .current_dir(&repo)
393 .output()
394 .map(|output| output.status.success())
395 .unwrap_or(false);
396 println!("laravel-artisan {}", if ok { "ok" } else { "missing" });
397 }
398 if framework == Framework::Hyperf {
399 let ok = Command::new("php")
400 .arg("bin/hyperf.php")
401 .arg("--help")
402 .current_dir(&repo)
403 .output()
404 .map(|output| output.status.success())
405 .unwrap_or(false);
406 println!("hyperf-cli {}", if ok { "ok" } else { "missing" });
407 }
408
409 if checks.iter().any(|(_, ok, required)| *required && !ok) {
410 bail!("doctor found missing required dependencies");
411 }
412 Ok(())
413 }
414
415 pub fn index(
416 repo: &Path,
417 project_name: Option<&str>,
418 requested_framework: Framework,
419 mode: IndexMode,
420 config_path: &Path,
421 ) -> Result<()> {
422 let repo = repo
423 .canonicalize()
424 .with_context(|| format!("open {}", repo.display()))?;
425 load_env_for(config_path);
426 let config = IndexerConfig::load(config_path)?;
427 let sanitizer = Sanitizer::default();
428
429 let packages = export_packages(&repo)?;
430 let package_names = packages
431 .packages
432 .iter()
433 .map(|package| package.name.clone())
434 .collect::<Vec<_>>();
435 let framework = adapters::detect_framework(&repo, requested_framework, &package_names);
436 let repo_name = packages.root.name.clone();
437 let files = scan_repo(&repo, &config.paths)?;
438
439 let mut symbols =
440 extract_symbols(&repo, &repo_name, framework, &files, &packages, &sanitizer)?;
441 let mut routes = adapters::extract_routes(&repo, &repo_name, framework, &sanitizer)?;
442 let schema = adapters::extract_schema(&repo, &repo_name)?;
443 let mut tests = if config.tests.include_tests {
444 extract_tests(&repo, &repo_name, framework, &files)?
445 } else {
446 Vec::new()
447 };
448 link_symbols_and_routes(&mut symbols, &mut routes, &mut tests, &config.tests);
449 link_routes_to_symbols(&mut symbols, &routes);
450 let packages_docs = package_docs(&repo_name, &packages);
451
452 let prefix = config.effective_index_prefix(&repo);
453 let run_id = run_id(&repo.display().to_string(), framework, mode);
454 let indexes = build_index_names(&prefix, &run_id, mode);
455
456 let manifest = RunManifest {
457 run_id: run_id.clone(),
458 repo_path: repo.display().to_string(),
459 git_commit: git_commit(&repo),
460 composer_lock_hash: file_hash(&repo.join("composer.lock"))?,
461 indexer_config_hash: config.hash()?,
462 framework: framework.as_str().to_string(),
463 include_vendor: config.paths.allow_vendor,
464 include_tests: config.tests.include_tests,
465 mode: mode.as_str().to_string(),
466 index_prefix: prefix.clone(),
467 indexes: indexes.clone(),
468 created_at: Utc::now(),
469 };
470
471 let connection = config.resolve_meili()?;
472 let meili = MeiliClient::new(connection.clone())?;
473 for (suffix, index_name) in &indexes {
474 meili.create_index(index_name)?;
475 match suffix.as_str() {
476 "symbols" => {
477 meili.apply_settings(index_name, &symbols_settings())?;
478 meili.replace_documents(index_name, &symbols)?;
479 }
480 "routes" => {
481 meili.apply_settings(index_name, &routes_settings())?;
482 meili.replace_documents(index_name, &routes)?;
483 }
484 "tests" => {
485 meili.apply_settings(index_name, &tests_settings())?;
486 meili.replace_documents(index_name, &tests)?;
487 }
488 "packages" => {
489 meili.apply_settings(index_name, &packages_settings())?;
490 meili.replace_documents(index_name, &packages_docs)?;
491 }
492 "schema" => {
493 meili.apply_settings(index_name, &schema_settings())?;
494 meili.replace_documents(index_name, &schema)?;
495 }
496 "runs" => {
497 meili.apply_settings(index_name, &runs_settings())?;
498 meili.replace_documents(index_name, std::slice::from_ref(&manifest))?;
499 }
500 _ => {}
501 }
502 }
503
504 let manifest_path = manifest_path(&repo, &run_id);
505 if let Some(parent) = manifest_path.parent() {
506 fs::create_dir_all(parent)?;
507 }
508 fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?;
509 upsert_project_registry(ProjectRecord {
510 name: project_name
511 .map(ToOwned::to_owned)
512 .unwrap_or_else(|| prefix.clone()),
513 repo_path: repo.display().to_string(),
514 index_prefix: prefix.clone(),
515 framework: framework.as_str().to_string(),
516 meili_host: connection.host.to_string(),
517 last_run_id: run_id.clone(),
518 updated_at: Utc::now(),
519 })?;
520
521 println!(
522 "Indexed {} files into {} ({})\n symbols: {}\n routes: {}\n tests: {}\n packages: {}\n schema: {}\n run: {}",
523 files.len(),
524 prefix,
525 mode.as_str(),
526 symbols.len(),
527 routes.len(),
528 tests.len(),
529 packages_docs.len(),
530 schema.len(),
531 manifest_path.display()
532 );
533 Ok(())
534 }
535
536 pub fn search(
537 query: &str,
538 project: Option<&str>,
539 index: SearchIndex,
540 framework: Option<Framework>,
541 config_path: &Path,
542 json_output: bool,
543 ) -> Result<()> {
544 load_env_for(config_path);
545 let mut config = IndexerConfig::load(config_path)?;
546 let current_dir = env::current_dir()?;
547 let selected_project = resolve_project_selector(project)?;
548 let prefix = selected_project
549 .as_ref()
550 .map(|item| item.index_prefix.clone())
551 .unwrap_or_else(|| config.effective_index_prefix(¤t_dir));
552 if let Some(project) = selected_project.as_ref()
553 && env::var("MEILI_HOST").is_err()
554 && config.meilisearch.host == "http://127.0.0.1:7700"
555 {
556 config.meilisearch.host = project.meili_host.clone();
557 }
558 let meili = MeiliClient::new(config.resolve_meili()?)?;
559 let compact = compact_query(query);
560 let filter =
561 framework.map(|framework| json!([format!("framework = {}", framework.as_str())]));
562
563 match index {
564 SearchIndex::All => {
565 let symbols = meili.search::<SymbolSearchDoc>(
566 &format!("{prefix}_symbols"),
567 {
568 let mut body = json!({
569 "q": compact,
570 "limit": config.search.exact_limit,
571 "showRankingScore": true,
572 "attributesToSearchOn": ["short_name", "fqn", "owner_class", "symbol_tokens"],
573 "attributesToRetrieve": ["fqn", "path", "line_start", "package_name", "related_tests", "missing_test_warning"],
574 "matchingStrategy": "all",
575 "filter": ["is_test = false"]
576 });
577 if let Some(filter) = &filter {
578 body["filter"] = filter.clone();
579 }
580 body
581 },
582 )?;
583 let routes = meili.search::<RouteSearchDoc>(
584 &format!("{prefix}_routes"),
585 json!({"q": compact, "limit": config.search.exact_limit, "showRankingScore": true}),
586 )?;
587 let tests = meili.search::<TestSearchDoc>(
588 &format!("{prefix}_tests"),
589 json!({"q": compact, "limit": config.search.natural_limit, "showRankingScore": true}),
590 )?;
591 let packages = meili.search::<PackageSearchDoc>(
592 &format!("{prefix}_packages"),
593 json!({"q": compact, "limit": config.search.natural_limit, "showRankingScore": true}),
594 )?;
595 let schema = meili.search::<SchemaSearchDoc>(
596 &format!("{prefix}_schema"),
597 json!({"q": compact, "limit": config.search.natural_limit, "showRankingScore": true}),
598 )?;
599
600 if json_output {
601 println!(
602 "{}",
603 serde_json::to_string_pretty(&json!({
604 "project": selected_project.as_ref().map(|item| item.name.clone()).unwrap_or(prefix.clone()),
605 "index_prefix": prefix,
606 "symbols": symbols,
607 "routes": routes,
608 "tests": tests,
609 "packages": packages,
610 "schema": schema,
611 }))?
612 );
613 } else {
614 if !symbols.hits.is_empty() {
615 println!("Symbols:");
616 for hit in &symbols.hits {
617 println!(
618 "{}\n path: {}:{}\n package: {}\n score: {:?}\n tests: {}\n",
619 hit.document.fqn,
620 hit.document.path,
621 hit.document.line_start,
622 hit.document.package_name,
623 hit.ranking_score,
624 hit.document.related_tests.join(", ")
625 );
626 }
627 }
628 if !routes.hits.is_empty() {
629 println!("Routes:");
630 for hit in &routes.hits {
631 println!(
632 "{} {} -> {}",
633 hit.document.method,
634 hit.document.uri,
635 hit.document
636 .action
637 .clone()
638 .unwrap_or_else(|| "unknown".to_string())
639 );
640 }
641 }
642 if !tests.hits.is_empty() {
643 println!("Tests:");
644 for hit in &tests.hits {
645 println!("{} -> {}", hit.document.fqn, hit.document.command);
646 }
647 }
648 if !packages.hits.is_empty() {
649 println!("Packages:");
650 for hit in &packages.hits {
651 println!("{} {:?}", hit.document.name, hit.document.version);
652 }
653 }
654 if !schema.hits.is_empty() {
655 println!("Schema:");
656 for hit in &schema.hits {
657 println!(
658 "{} {:?} {}:{}",
659 hit.document.operation,
660 hit.document.table,
661 hit.document.path,
662 hit.document.line_start
663 );
664 }
665 }
666 }
667 }
668 SearchIndex::Symbols => {
669 let index_name = format!("{prefix}_{}", index.suffix());
670 let mut body = json!({
671 "q": compact,
672 "limit": config.search.exact_limit,
673 "showRankingScore": true,
674 "attributesToSearchOn": ["short_name", "fqn", "owner_class", "symbol_tokens"],
675 "attributesToRetrieve": [
676 "fqn",
677 "path",
678 "line_start",
679 "package_name",
680 "related_tests",
681 "missing_test_warning"
682 ],
683 "matchingStrategy": "all",
684 "filter": ["is_test = false"]
685 });
686 if let Some(filter) = filter {
687 body["filter"] = filter;
688 }
689 let response = meili.search::<SymbolSearchDoc>(&index_name, body)?;
690 if json_output {
691 println!("{}", serde_json::to_string_pretty(&response)?);
692 } else {
693 for hit in response.hits {
694 println!(
695 "{}\n path: {}:{}\n package: {}\n score: {:?}\n tests: {}\n",
696 hit.document.fqn,
697 hit.document.path,
698 hit.document.line_start,
699 hit.document.package_name,
700 hit.ranking_score,
701 hit.document.related_tests.join(", ")
702 );
703 }
704 }
705 }
706 SearchIndex::Routes => {
707 let index_name = format!("{prefix}_{}", index.suffix());
708 let response = meili.search::<RouteDoc>(
709 &index_name,
710 json!({"q": compact, "limit": config.search.exact_limit, "showRankingScore": true}),
711 )?;
712 if json_output {
713 println!("{}", serde_json::to_string_pretty(&response)?);
714 } else {
715 for hit in response.hits {
716 println!(
717 "{} {} -> {}",
718 hit.document.method,
719 hit.document.uri,
720 hit.document.action.unwrap_or_else(|| "unknown".to_string())
721 );
722 }
723 }
724 }
725 SearchIndex::Tests => {
726 let index_name = format!("{prefix}_{}", index.suffix());
727 let response = meili.search::<TestDoc>(
728 &index_name,
729 json!({"q": compact, "limit": config.search.natural_limit, "showRankingScore": true}),
730 )?;
731 if json_output {
732 println!("{}", serde_json::to_string_pretty(&response)?);
733 } else {
734 for hit in response.hits {
735 println!("{} -> {}", hit.document.fqn, hit.document.command);
736 }
737 }
738 }
739 SearchIndex::Packages => {
740 let index_name = format!("{prefix}_{}", index.suffix());
741 let response = meili.search::<PackageDoc>(
742 &index_name,
743 json!({"q": compact, "limit": config.search.natural_limit, "showRankingScore": true}),
744 )?;
745 if json_output {
746 println!("{}", serde_json::to_string_pretty(&response)?);
747 } else {
748 for hit in response.hits {
749 println!("{} {:?}", hit.document.name, hit.document.version);
750 }
751 }
752 }
753 SearchIndex::Schema => {
754 let index_name = format!("{prefix}_{}", index.suffix());
755 let response = meili.search::<SchemaDoc>(
756 &index_name,
757 json!({"q": compact, "limit": config.search.natural_limit, "showRankingScore": true}),
758 )?;
759 if json_output {
760 println!("{}", serde_json::to_string_pretty(&response)?);
761 } else {
762 for hit in response.hits {
763 println!(
764 "{} {:?} {}:{}",
765 hit.document.operation,
766 hit.document.table,
767 hit.document.path,
768 hit.document.line_start
769 );
770 }
771 }
772 }
773 }
774 Ok(())
775 }
776
777 pub fn validate(symbol: &str, config_path: &Path, json_output: bool) -> Result<()> {
778 load_env_for(config_path);
779 let config = IndexerConfig::load(config_path)?;
780 let prefix = config.effective_index_prefix(&env::current_dir()?);
781 let meili = MeiliClient::new(config.resolve_meili()?)?;
782 let response = meili.search::<TestDoc>(
783 &format!("{prefix}_tests"),
784 json!({
785 "q": compact_query(symbol),
786 "limit": 10,
787 "showRankingScore": true,
788 "attributesToSearchOn": ["covered_symbols", "referenced_symbols", "routes_called", "fqn"]
789 }),
790 )?;
791 if json_output {
792 println!("{}", serde_json::to_string_pretty(&response)?);
793 return Ok(());
794 }
795
796 let mut hits = response.hits;
797 hits.sort_by(|left, right| {
798 right
799 .document
800 .confidence
801 .partial_cmp(&left.document.confidence)
802 .unwrap()
803 });
804 println!("Validation for {symbol}");
805 let mut strong = 0usize;
806 for hit in &hits {
807 println!(
808 "- {} | confidence {:.2} | {}",
809 hit.document.fqn, hit.document.confidence, hit.document.command
810 );
811 if hit.document.confidence >= config.tests.validate_threshold {
812 strong += 1;
813 }
814 }
815 if strong == 0 {
816 println!(
817 "Validation warning: No related test with confidence >= {:.2} was found.",
818 config.tests.validate_threshold
819 );
820 }
821 Ok(())
822 }
823
824 pub fn remove(project: &str, keep_indexes: bool, config_path: &Path) -> Result<()> {
825 load_env_for(config_path);
826 let path = default_project_registry_path();
827 let mut registry = ProjectRegistry::load(&path)?;
828 let record = registry
829 .remove(project)
830 .ok_or_else(|| anyhow!("project '{}' not found in {}", project, path.display()))?;
831
832 if !keep_indexes {
833 let mut config = IndexerConfig::load(config_path)?;
834 if env::var("MEILI_HOST").is_err() && config.meilisearch.host == "http://127.0.0.1:7700"
835 {
836 config.meilisearch.host = record.meili_host.clone();
837 }
838 let meili = MeiliClient::new(config.resolve_meili()?)?;
839 for suffix in ["symbols", "routes", "tests", "packages", "schema", "runs"] {
840 meili.delete_index(&format!("{}_{}", record.index_prefix, suffix))?;
841 }
842 }
843
844 registry.save(&path)?;
845 println!(
846 "Removed project '{}' from {}\n repo: {}\n indexes_removed: {}",
847 record.name,
848 path.display(),
849 record.repo_path,
850 if keep_indexes { "no" } else { "yes" }
851 );
852 Ok(())
853 }
854
855 pub fn verify(config_path: &Path) -> Result<()> {
856 load_env_for(config_path);
857 let config = IndexerConfig::load(config_path)?;
858 let prefix = config.effective_index_prefix(&env::current_dir()?);
859 let meili = MeiliClient::new(config.resolve_meili()?)?;
860 println!("health {}", meili.health()?);
861 for suffix in ["symbols", "routes", "tests", "packages", "schema", "runs"] {
862 let stats = meili.stats(&format!("{prefix}_{suffix}"))?;
863 let documents = stats
864 .get("numberOfDocuments")
865 .or_else(|| stats.get("numberOfDocumentsTotal"));
866 println!("{suffix:8} docs {:?}", documents);
867 }
868 let smoke = meili.search::<SymbolDoc>(
869 &format!("{prefix}_symbols"),
870 json!({"q": "consent", "limit": 3, "showRankingScore": true}),
871 )?;
872 println!("smoke-search hits {}", smoke.hits.len());
873 Ok(())
874 }
875
876 pub fn promote(config_path: &Path, run_id: Option<&str>) -> Result<()> {
877 load_env_for(config_path);
878 let config = IndexerConfig::load(config_path)?;
879 let repo = env::current_dir()?;
880 let manifest = load_manifest(&repo, run_id)?;
881 let meili = MeiliClient::new(config.resolve_meili()?)?;
882 let prefix = manifest.index_prefix.clone();
883
884 let mut swaps = Vec::new();
885 for suffix in ["symbols", "routes", "tests", "packages", "schema"] {
886 let stable = format!("{prefix}_{suffix}");
887 let staged = manifest
888 .indexes
889 .get(suffix)
890 .cloned()
891 .ok_or_else(|| anyhow!("manifest missing {suffix} index"))?;
892 if staged == stable {
893 continue;
894 }
895 meili.create_index(&stable)?;
896 swaps.push((stable, staged));
897 }
898 if swaps.is_empty() {
899 println!("No staged indexes to promote");
900 return Ok(());
901 }
902 meili.swap_indexes(swaps)?;
903 println!("Promoted run {}", manifest.run_id);
904 Ok(())
905 }
906
907 fn load_env_for(config_path: &Path) {
908 if let Some(root) = config_path.parent().and_then(Path::parent) {
909 let env_path = root.join(".env");
910 if env_path.exists() {
911 let _ = dotenvy::from_path_override(env_path);
912 }
913 }
914 }
915
916 fn command_exists(name: &str) -> bool {
917 Command::new("sh")
918 .arg("-lc")
919 .arg(format!("command -v {name}"))
920 .output()
921 .map(|output| output.status.success())
922 .unwrap_or(false)
923 }
924
925 fn file_hash(path: &Path) -> Result<String> {
926 if !path.exists() {
927 return Ok("missing".to_string());
928 }
929 let bytes = fs::read(path)?;
930 let mut hasher = Sha1::new();
931 hasher.update(&bytes);
932 Ok(format!("{:x}", hasher.finalize()))
933 }
934
935 fn git_commit(repo: &Path) -> String {
936 Command::new("git")
937 .args(["rev-parse", "HEAD"])
938 .current_dir(repo)
939 .output()
940 .ok()
941 .filter(|output| output.status.success())
942 .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
943 .unwrap_or_else(|| "unknown".to_string())
944 }
945
946 fn build_index_names(prefix: &str, run_id: &str, mode: IndexMode) -> HashMap<String, String> {
947 let mut indexes = HashMap::new();
948 for suffix in ["symbols", "routes", "tests", "packages", "schema"] {
949 let name = match mode {
950 IndexMode::Clean => format!("{prefix}_{suffix}"),
951 IndexMode::Staged => format!("{prefix}_{suffix}_tmp_{run_id}"),
952 };
953 indexes.insert(suffix.to_string(), name);
954 }
955 indexes.insert("runs".to_string(), format!("{prefix}_runs"));
956 indexes
957 }
958
959 fn package_docs(repo_name: &str, packages: &ComposerExport) -> Vec<PackageDoc> {
960 std::iter::once(&packages.root)
961 .chain(packages.packages.iter())
962 .map(|package| PackageDoc {
963 id: make_stable_id(&[repo_name, &package.name]),
964 repo: repo_name.to_string(),
965 name: package.name.clone(),
966 version: package.version.clone(),
967 package_type: package.package_type.clone(),
968 description: package.description.clone(),
969 install_path: package.install_path.clone(),
970 keywords: package.keywords.clone(),
971 is_root: package.is_root,
972 })
973 .collect()
974 }
975
976 fn link_routes_to_symbols(symbols: &mut [SymbolDoc], routes: &[RouteDoc]) {
977 let route_ids_by_symbol =
978 routes
979 .iter()
980 .fold(HashMap::<String, Vec<String>>::new(), |mut map, route| {
981 for related in &route.related_symbols {
982 map.entry(related.clone())
983 .or_default()
984 .push(route.id.clone());
985 }
986 map
987 });
988 for symbol in symbols {
989 if let Some(route_ids) = route_ids_by_symbol.get(&symbol.fqn) {
990 symbol.route_ids = route_ids.clone();
991 }
992 }
993 }
994
995 fn load_manifest(repo: &Path, run_id: Option<&str>) -> Result<RunManifest> {
996 let build_dir = repo.join("build/index-runs");
997 let path = if let Some(run_id) = run_id {
998 build_dir.join(format!("{run_id}.json"))
999 } else {
1000 latest_manifest(&build_dir)?
1001 };
1002 serde_json::from_slice(
1003 &fs::read(&path).with_context(|| format!("read {}", path.display()))?,
1004 )
1005 .with_context(|| format!("parse {}", path.display()))
1006 }
1007
1008 fn latest_manifest(dir: &Path) -> Result<PathBuf> {
1009 let mut manifests = fs::read_dir(dir)?
1010 .filter_map(Result::ok)
1011 .filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("json"))
1012 .collect::<Vec<_>>();
1013 manifests.sort_by_key(|entry| entry.metadata().and_then(|meta| meta.modified()).ok());
1014 manifests
1015 .pop()
1016 .map(|entry| entry.path())
1017 .ok_or_else(|| anyhow!("no run manifests found in {}", dir.display()))
1018 }
1019
1020 fn upsert_project_registry(record: ProjectRecord) -> Result<()> {
1021 let path = default_project_registry_path();
1022 let mut registry = ProjectRegistry::load(&path)?;
1023 registry.upsert(record);
1024 registry.save(&path)
1025 }
1026
1027 fn resolve_project_selector(selector: Option<&str>) -> Result<Option<ProjectRecord>> {
1028 let Some(selector) = selector else {
1029 return Ok(None);
1030 };
1031 let path = default_project_registry_path();
1032 let registry = ProjectRegistry::load(&path)?;
1033 registry
1034 .resolve(selector)
1035 .cloned()
1036 .map(Some)
1037 .ok_or_else(|| anyhow!("project '{}' not found in {}", selector, path.display()))
1038 }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043 use std::fs;
1044
1045 use tempfile::tempdir;
1046
1047 use super::init_workspace_with_connect_path;
1048
1049 #[test]
1050 fn init_scaffolds_default_files() {
1051 let temp = tempdir().unwrap();
1052 let connect_path = temp.path().join(".config/meilisearch/connect.json");
1053 init_workspace_with_connect_path(temp.path(), &connect_path, false).unwrap();
1054
1055 assert!(temp.path().join("config/indexer.toml").exists());
1056 assert!(temp.path().join(".env.example").exists());
1057 assert!(temp.path().join("docker-compose.meilisearch.yml").exists());
1058 assert!(connect_path.exists());
1059 }
1060
1061 #[test]
1062 fn init_refuses_to_overwrite_without_force() {
1063 let temp = tempdir().unwrap();
1064 fs::create_dir_all(temp.path().join("config")).unwrap();
1065 fs::write(temp.path().join("config/indexer.toml"), "existing").unwrap();
1066 let connect_path = temp.path().join(".config/meilisearch/connect.json");
1067
1068 let err = init_workspace_with_connect_path(temp.path(), &connect_path, false).unwrap_err();
1069 assert!(err.to_string().contains("already exists"));
1070 }
1071
1072 #[test]
1073 fn init_does_not_overwrite_existing_global_connect_file() {
1074 let temp = tempdir().unwrap();
1075 let connect_path = temp.path().join(".config/meilisearch/connect.json");
1076 fs::create_dir_all(connect_path.parent().unwrap()).unwrap();
1077 fs::write(
1078 &connect_path,
1079 "{\"url\":\"http://example.test:7700\",\"apiKey\":\"real\"}\n",
1080 )
1081 .unwrap();
1082
1083 init_workspace_with_connect_path(temp.path(), &connect_path, false).unwrap();
1084
1085 assert_eq!(
1086 fs::read_to_string(&connect_path).unwrap(),
1087 "{\"url\":\"http://example.test:7700\",\"apiKey\":\"real\"}\n"
1088 );
1089 }
1090}