Skip to main content

source_map_php/
lib.rs

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(&current_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}