package cli import ( "errors" "flag" "fmt" "io" "os" "path/filepath" "strconv" "strings" ck "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/audita/internal/core/config" ) var systemConfigPaths = []string{"/usr/local/etc/audita/config.yml", "/etc/audita/config.yml"} type configInput struct { ConfigPath string ConfigPathSet bool Overlay string View string TranscriptPath string GlossaryPath string OutputPath string ReportDirSet bool Overrides []ck.Override } type selectedConfig struct { Path string Source string Result *ck.Result[config.Config] } // parseConfigInput owns the common setting flags and process-only path flags. // A transcript before flags and one after flags are both accepted. func parseConfigInput(command string, args []string, output io.Writer) (configInput, bool, error) { var input configInput fs := flag.NewFlagSet(command, flag.ContinueOnError) fs.SetOutput(output) configPath := fs.String("config", "", "Main configuration YAML file") overlay := fs.String("overlay", "", "Selected configuration overlay") if command == "print-effective" { fs.String("view", "settings", "Effective view: settings, profiles, or explain") } settingPaths := map[string]ck.Path{ "proposal-profile": ck.Keys("llm", "proposal_profile"), "validation-profile": ck.Keys("llm", "validation_profile"), "output-schema": ck.Keys("output", "schema"), "validation-max-prompt-tokens": ck.Keys("validation", "max_prompt_tokens"), "target-sections": ck.Keys("chunking", "target_sections"), "max-section-tokens": ck.Keys("chunking", "max_section_tokens"), "min-section-tokens": ck.Keys("chunking", "min_section_tokens"), "normalize-max-segment-gap": ck.Keys("normalization", "max_segment_gap"), "normalize-ellipsis-gap": ck.Keys("normalization", "ellipsis_gap"), "normalize-max-segment-duration": ck.Keys("normalization", "max_segment_duration"), "normalize-max-segment-tokens": ck.Keys("normalization", "max_segment_tokens"), "glossary-confidence-threshold": ck.Keys("thresholds", "glossary"), "homophones-confidence-threshold": ck.Keys("thresholds", "homophones"), "spoken-word-confidence-threshold": ck.Keys("thresholds", "spoken_word"), "grammar-confidence-threshold": ck.Keys("thresholds", "grammar"), "transcript-description": ck.Keys("context", "description"), "reporting": ck.Keys("reporting", "enabled"), "report-dir": ck.Keys("reporting", "stdout_dir"), "debug": ck.Keys("diagnostics", "debug"), "work-dir": ck.Keys("diagnostics", "work_dir"), "modules": ck.Keys("pipeline", "modules"), } for name := range settingPaths { switch name { case "reporting", "debug": fs.Bool(name, false, "Setting override") case "validation-max-prompt-tokens", "target-sections", "max-section-tokens", "min-section-tokens", "normalize-max-segment-tokens": fs.Int(name, 0, "Setting override") case "normalize-max-segment-gap", "normalize-ellipsis-gap", "normalize-max-segment-duration", "glossary-confidence-threshold", "homophones-confidence-threshold", "spoken-word-confidence-threshold", "grammar-confidence-threshold": fs.Float64(name, 0, "Setting override") default: fs.String(name, "", "Setting override") } } if command == "process" { fs.String("glossary", "", "Glossary YAML file") fs.String("output", "", "Corrected transcript output file") } parseArgs := args front := "" if command == "process" && len(args) > 0 && !strings.HasPrefix(args[0], "-") { front, parseArgs = args[0], args[1:] } if err := fs.Parse(parseArgs); err != nil { if errors.Is(err, flag.ErrHelp) { return configInput{}, true, nil } return configInput{}, false, err } positions := fs.Args() if front != "" { positions = append([]string{front}, positions...) } if command == "process" { if len(positions) != 1 { return configInput{}, false, fmt.Errorf("expected exactly one transcript JSON path") } input.TranscriptPath = positions[0] input.GlossaryPath = fs.Lookup("glossary").Value.String() input.OutputPath = fs.Lookup("output").Value.String() if strings.TrimSpace(input.GlossaryPath) == "" { return configInput{}, false, fmt.Errorf("--glossary is required") } } else if len(positions) != 0 { return configInput{}, false, fmt.Errorf("unexpected positional arguments") } input.ConfigPath, input.Overlay = *configPath, *overlay if command == "print-effective" { input.View = fs.Lookup("view").Value.String() switch input.View { case "settings", "profiles", "explain": default: return configInput{}, false, fmt.Errorf("unknown effective view %q", input.View) } } fs.Visit(func(f *flag.Flag) { if f.Name == "config" { input.ConfigPathSet = true } if f.Name == "report-dir" { input.ReportDirSet = true } path, ok := settingPaths[f.Name] if !ok { return } var value any = f.Value.String() switch f.Name { case "reporting", "debug": value, _ = strconv.ParseBool(f.Value.String()) case "modules": modules, err := config.ParseModulesCSV(f.Value.String()) if err != nil { value = err } else { values := make([]any, len(modules)) for i, module := range modules { values[i] = module } value = values } case "validation-max-prompt-tokens", "target-sections", "max-section-tokens", "min-section-tokens", "normalize-max-segment-tokens": value, _ = strconv.Atoi(f.Value.String()) case "normalize-max-segment-gap", "normalize-ellipsis-gap", "normalize-max-segment-duration", "glossary-confidence-threshold", "homophones-confidence-threshold", "spoken-word-confidence-threshold", "grammar-confidence-threshold": value, _ = strconv.ParseFloat(f.Value.String(), 64) } input.Overrides = append(input.Overrides, ck.Override{Path: path, Value: value, Flag: "--" + f.Name}) }) for _, override := range input.Overrides { if err, ok := override.Value.(error); ok { return configInput{}, false, fmt.Errorf("%s: %w", override.Flag, err) } } return input, false, nil } func selectConfigPath(explicit string, set bool, stat func(string) (os.FileInfo, error), candidates []string) (string, string, error) { if set { if strings.TrimSpace(explicit) == "" { return "", "", fmt.Errorf("--config requires a nonempty path") } if _, err := stat(explicit); err != nil { return "", "", fmt.Errorf("config file %q: %w", explicit, err) } return explicit, "flag", nil } for _, path := range candidates { if _, err := stat(path); err == nil { return path, "default", nil } else if !os.IsNotExist(err) { return "", "", fmt.Errorf("config file %q: %w", path, err) } } return "", "", fmt.Errorf("no configuration file found; select a config with backend and profile settings") } func loadConfigInput(input configInput, command string) (selectedConfig, error) { if command == "validate" && (!input.ConfigPathSet || strings.TrimSpace(input.ConfigPath) == "") { return selectedConfig{}, fmt.Errorf("--config is required") } path, source, err := selectConfigPath(input.ConfigPath, input.ConfigPathSet, os.Stat, systemConfigPaths) if err != nil { return selectedConfig{}, err } loaded, err := config.Load(ck.File(path), input.Overlay, input.Overrides...) if err != nil { return selectedConfig{}, err } return selectedConfig{Path: path, Source: source, Result: loaded}, nil } func configInputError(err error) string { var loadErr *ck.LoadError if !errors.As(err, &loadErr) { return err.Error() } var messages []string for _, diagnostic := range loadErr.Diagnostics() { messages = append(messages, diagnostic.Message) } if len(messages) == 0 { return err.Error() } return strings.Join(messages, "; ") } func selectedReportDir(cfg config.Config, outputPath string, reportDirSet bool) (string, error) { if !cfg.Reporting.Enabled { return "", nil } var reportDir string if outputPath != "" { if reportDirSet { reportDir = cfg.Reporting.StdoutDir } else { reportDir = filepath.Dir(outputPath) } } else { reportDir = cfg.Reporting.StdoutDir } if strings.TrimSpace(reportDir) == "" { return "", fmt.Errorf("report directory is required when reporting is enabled for the selected destination") } return reportDir, nil }