package cmd import ( "context" "encoding/json" "errors" "fmt" "net/url" "path" "regexp" "sort" "strings" "time" "github.com/spf13/cobra" appconfig "github.com/yourorg/devour/internal/config" "github.com/yourorg/devour/internal/scraper" "github.com/yourorg/devour/internal/search" "github.com/yourorg/devour/internal/storage" ) var askCmd = &cobra.Command{ Use: "ask ", Short: "Ask docs directly and get a structured answer", Long: `Fetch official documentation for a language, rank the most relevant sections, and return a structured answer with sources. Examples: devour ask --lang go "how to regex match digits" --format json devour ask --lang python "async timeout example" devour ask --lang rust "tokio spawn best practice" --max-sources 5`, Args: cobra.MinimumNArgs(1), RunE: runAsk, } var ( askLanguage string askFormat string askMaxSources int askTimeoutSec int ) type askResponse struct { Query string `json:"query"` Language string `json:"language"` SearchedTerms []string `json:"searched_terms"` Retrieval askRetrieval `json:"retrieval"` Answer askAnswer `json:"answer"` Sources []askSource `json:"sources"` Confidence float64 `json:"confidence"` FetchedAt time.Time `json:"fetched_at"` } type askRetrieval struct { Mode string `json:"mode"` LocalHits int `json:"local_hits"` FallbackFetched int `json:"fallback_fetch_count"` } type askAnswer struct { Summary string `json:"summary"` RecommendedAPI []string `json:"recommended_api"` Example string `json:"example,omitempty"` Notes []string `json:"notes"` } type askSource struct { DocID string `json:"doc_id"` Title string `json:"title"` URL string `json:"url"` Type string `json:"type"` SearchTerm string `json:"search_term"` Relevance float64 `json:"relevance"` Snippet string `json:"snippet"` } type rankedDoc struct { doc *scraper.Document score float64 searchTerm string } type askPersistenceWarning struct { operation string cause error } func (w *askPersistenceWarning) Error() string { return fmt.Sprintf("persistence warning: %s: %v", w.operation, w.cause) } func (w *askPersistenceWarning) Unwrap() error { return w.cause } func init() { askCmd.Flags().StringVar(&askLanguage, "lang", "", "language/framework (required)") askCmd.Flags().StringVarP(&askFormat, "format", "f", "json", "output format (json, text)") askCmd.Flags().IntVar(&askMaxSources, "max-sources", 5, "maximum number of source snippets to return") askCmd.Flags().IntVar(&askTimeoutSec, "timeout", 60, "request timeout in seconds") _ = askCmd.MarkFlagRequired("lang") } func runAsk(cmd *cobra.Command, args []string) error { question := strings.TrimSpace(strings.Join(args, " ")) if question == "" { return fmt.Errorf("question is required") } languageIn := strings.ToLower(strings.TrimSpace(askLanguage)) language, ok := normalizeLanguage(languageIn) if !ok { return fmt.Errorf("unsupported language: %s. Supported: %s", languageIn, strings.Join(supportedLanguages(), ", ")) } terms := deriveSearchTerms(language, question) if len(terms) == 0 { return fmt.Errorf("could not derive a search term from the question") } if askMaxSources <= 0 { askMaxSources = 5 } if askTimeoutSec <= 0 { askTimeoutSec = 60 } cfg, err := loadAppConfig() if err != nil { return fmt.Errorf("load app config for ask command: %w", err) } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(askTimeoutSec)*time.Second) defer cancel() localRanked, localErr := gatherLocalAskDocs(ctx, cfg, language, question, terms, askMaxSources*4) if localErr != nil { localRanked = nil } ranked := append([]rankedDoc{}, localRanked...) retrievalMode := "local-first" fallbackNeeded := shouldFallbackToLive(localRanked, terms) fallbackCount := 0 fetchErrors := []error{} if localErr != nil { fetchErrors = append(fetchErrors, fmt.Errorf("local retrieval failed: %w", localErr)) } if fallbackNeeded { fallbackDocs, fetched, errs := fetchAskDocsFromLive(ctx, cfg, language, question, terms) fallbackCount = fetched fetchErrors = append(fetchErrors, errs...) ranked = mergeRankedDocs(ranked, fallbackDocs) if fetched > 0 { retrievalMode = "local+live" } else if len(localRanked) == 0 { retrievalMode = "live" } } if len(ranked) == 0 { if len(fetchErrors) == 0 { return fmt.Errorf("no docs found for %q", language) } return fmt.Errorf("no docs found for %q: %w", language, errors.Join(fetchErrors...)) } sort.Slice(ranked, func(i, j int) bool { if ranked[i].score == ranked[j].score { return ranked[i].doc.Title < ranked[j].doc.Title } return ranked[i].score > ranked[j].score }) if askMaxSources > len(ranked) { askMaxSources = len(ranked) } top := ranked[:askMaxSources] response := askResponse{ Query: question, Language: language, SearchedTerms: terms, Retrieval: askRetrieval{ Mode: retrievalMode, LocalHits: len(localRanked), FallbackFetched: fallbackCount, }, Answer: askAnswer{ Summary: summarizeTopDocs(question, top), RecommendedAPI: ensureAPISlice(extractRecommendedAPI(top)), Example: extractExample(top), Notes: []string{ "Retrieval is hybrid local-first: local index first, then targeted live fetch fallback.", "Relevance uses lexical ranking across local snippets and fetched docs.", }, }, Sources: buildAskSources(question, top), Confidence: computeConfidence(question, top), FetchedAt: time.Now(), } for _, fetchErr := range fetchErrors { var persistenceWarning *askPersistenceWarning if errors.As(fetchErr, &persistenceWarning) { response.Answer.Notes = append(response.Answer.Notes, persistenceWarning.Error()) } } switch strings.ToLower(askFormat) { case "text": printAskText(response) return nil case "json": enc := json.NewEncoder(cmd.OutOrStdout()) enc.SetIndent("", " ") return enc.Encode(response) default: return fmt.Errorf("unsupported format: %s", askFormat) } } func gatherLocalAskDocs(ctx context.Context, cfg *appconfig.Config, language, question string, terms []string, limit int) ([]rankedDoc, error) { engine := search.NewEngine(cfg) query := strings.TrimSpace(question + " " + language + " " + strings.Join(terms, " ")) results, _, err := engine.Search(ctx, query, search.SearchOptions{Limit: limit * 2}) if err != nil { return nil, err } out := make([]rankedDoc, 0, len(results)) for _, result := range results { if !resultMatchesLanguage(result, language) { continue } out = append(out, rankedDoc{ doc: &scraper.Document{ ID: result.DocID, Source: result.Source, Type: result.Type, Title: result.Title, Content: result.Snippet, URL: result.URL, }, score: result.Score, searchTerm: "local-index", }) } return out, nil } func resultMatchesLanguage(result search.Result, language string) bool { urlLower := strings.ToLower(result.URL) typeLower := strings.ToLower(result.Type) sourceLower := strings.ToLower(result.Source) titleLower := strings.ToLower(result.Title) matchAny := func(parts ...string) bool { for _, p := range parts { if strings.Contains(urlLower, p) || strings.Contains(typeLower, p) || strings.Contains(sourceLower, p) || strings.Contains(titleLower, p) { return true } } return false } switch language { case "go": return matchAny("pkg.go.dev", "go-") case "rust": return matchAny("docs.rs", "rust-") case "python": return matchAny("docs.python.org", "python") case "java": return matchAny("docs.oracle.com", "java") case "spring": return matchAny("docs.spring.io", "spring") case "typescript": return matchAny("typescriptlang.org", "ts-") case "react": return matchAny("react.dev", "react") case "vue": return matchAny("vuejs.org", "vue") case "nuxt": return matchAny("nuxt.com", "nuxt") case "docker": return matchAny("docs.docker.com", "docker") case "cloudflare": return matchAny("developers.cloudflare.com", "cloudflare") case "astro": return matchAny("docs.astro.build", "astro") case "csharp": return matchAny("learn.microsoft.com", "c#") case "kotlin": return matchAny("kotlinlang.org", "kotlin") case "php": return matchAny("php.net", "php") case "ruby": return matchAny("ruby-doc.org", "ruby") case "elixir": return matchAny("hexdocs.pm", "elixir") case "nextjs": return matchAny("nextjs.org", "next") case "svelte": return matchAny("svelte.dev", "svelte") case "angular": return matchAny("angular.dev", "angular") case "remix": return matchAny("remix.run", "remix") case "solid": return matchAny("solidjs.com", "solid") case "express": return matchAny("expressjs.com", "express") default: return true } } func fetchAskDocsFromLive(ctx context.Context, cfg *appconfig.Config, language, question string, terms []string) ([]rankedDoc, int, []error) { sourceType := scraper.SourceType(mapLanguageToType(language)) if sourceType == "" { return nil, 0, []error{fmt.Errorf("unsupported language: %s", language)} } sc := toScraperConfig(cfg, 2) sc.MaxDepth = 1 s := scraper.NewScraper(sourceType, sc) if s == nil { return nil, 0, []error{fmt.Errorf("no scraper for %s (%s)", language, sourceType)} } var ranked []rankedDoc var fetchErrors []error seenURL := make(map[string]bool) totalFetched := 0 fetchedDocs := make([]*scraper.Document, 0) for _, term := range terms { docURLs, err := candidateDocURLs(language, term) if err != nil { fetchErrors = append(fetchErrors, fmt.Errorf("%s: %w", term, err)) continue } termFetched := false termErrors := make([]error, 0, len(docURLs)) for _, docURL := range docURLs { if seenURL[docURL] { continue } seenURL[docURL] = true source := &scraper.Source{ Name: fmt.Sprintf("%s:%s", language, term), Type: sourceType, URL: docURL, } applySourceProfile(source) docs, err := s.Scrape(ctx, source) if err != nil { termErrors = append(termErrors, fmt.Errorf("%s: %w", docURL, err)) continue } if len(docs) == 0 { termErrors = append(termErrors, fmt.Errorf("%s: no documents extracted", docURL)) continue } termFetched = true totalFetched += len(docs) fetchedDocs = append(fetchedDocs, docs...) for _, doc := range docs { ranked = append(ranked, rankedDoc{ doc: doc, score: scoreDocument(question, doc), searchTerm: term, }) } // Stop after first successful candidate for this term. break } if !termFetched && len(termErrors) > 0 { fetchErrors = append(fetchErrors, errors.Join(termErrors...)) } } // Persist fallback docs for future local-first queries. if len(fetchedDocs) > 0 { if _, err := storage.SaveDocuments(fetchedDocs, storage.SaveOptions{ Format: "json", OutputDir: cfg.Storage.DocsDir, AllowEmpty: true, }); err != nil { fetchErrors = append(fetchErrors, &askPersistenceWarning{ operation: "save fallback docs", cause: err, }) } if cfg.Indexing.Enabled { engine := search.NewEngine(cfg) if _, err := engine.Rebuild(context.Background()); err != nil { fetchErrors = append(fetchErrors, &askPersistenceWarning{ operation: "rebuild index after fallback", cause: err, }) } } } return ranked, totalFetched, fetchErrors } func candidateDocURLs(language, term string) ([]string, error) { primary, err := constructDocURL(language, term) if err != nil { return nil, err } seen := map[string]bool{} out := make([]string, 0, 5) add := func(raw string) { raw = strings.TrimSpace(raw) if raw == "" || seen[raw] { return } seen[raw] = true out = append(out, raw) } add(primary) switch language { case "nextjs": add("https://nextjs.org/docs/app/building-your-application/routing") add("https://nextjs.org/docs/app/building-your-application/data-fetching") add("https://nextjs.org/docs") case "svelte": add("https://svelte.dev/docs/kit") add("https://svelte.dev/docs/svelte/overview") case "angular": add("https://angular.dev/guide/http") add("https://angular.dev/guide/components") case "remix": add("https://v2.remix.run/docs/file-conventions/routes") add("https://v2.remix.run/docs") case "solid": add("https://github.com/solidjs/solid-docs") case "express": add("https://expressjs.com/en/guide/routing.html") add("https://expressjs.com/en/guide/using-middleware.html") } return out, nil } func mergeRankedDocs(primary, secondary []rankedDoc) []rankedDoc { merged := make([]rankedDoc, 0, len(primary)+len(secondary)) seen := map[string]bool{} add := func(item rankedDoc) { if item.doc == nil { return } key := strings.TrimSpace(item.doc.URL) if key == "" { key = strings.TrimSpace(item.doc.ID) } if key == "" { key = strings.TrimSpace(item.doc.Title) + ":" + item.searchTerm } if seen[key] { return } seen[key] = true merged = append(merged, item) } for _, p := range primary { add(p) } for _, s := range secondary { add(s) } return merged } func topLocalMatchesTerms(ranked []rankedDoc, terms []string) bool { if len(ranked) == 0 || len(terms) == 0 { return false } maxCheck := len(ranked) if maxCheck > 5 { maxCheck = 5 } for i := 0; i < maxCheck; i++ { if ranked[i].doc == nil { continue } hay := strings.ToLower(ranked[i].doc.Title + " " + ranked[i].doc.URL + " " + ranked[i].doc.Content) for _, term := range terms { if strings.Contains(hay, strings.ToLower(term)) { return true } } } return false } func shouldFallbackToLive(localRanked []rankedDoc, terms []string) bool { if len(localRanked) == 0 { return true } // Very low confidence local ranking should trigger a live fetch. if localRanked[0].score < 0.2 { return true } if !topLocalMatchesTerms(localRanked, terms) { return true } // If we only have one weak match, try live fallback to improve recall. if len(localRanked) < 2 && localRanked[0].score < 1.5 { return true } return false } func deriveSearchTerms(language, question string) []string { q := strings.ToLower(question) var terms []string has := func(parts ...string) bool { for _, p := range parts { if strings.Contains(q, p) { return true } } return false } switch language { case "go": if has("regex", "regexp", "regular expression") { terms = append(terms, "regexp") } if has("http", "request", "response", "server", "client") { terms = append(terms, "net/http") } if has("json") { terms = append(terms, "encoding/json") } case "rust": if has("regex", "regexp") { terms = append(terms, "regex") } if has("async", "await", "task") { terms = append(terms, "tokio") } case "python": if has("regex", "regexp", "regular expression") { terms = append(terms, "re") } if has("async", "await", "task") { terms = append(terms, "asyncio") } case "java": if has("regex", "regexp") { terms = append(terms, "java/util/regex/package-summary") } if has("http", "client") { terms = append(terms, "java/net/http/package-summary") } case "spring": if has("mcp") { terms = append(terms, "mcp-overview") } terms = append(terms, "features") case "typescript": if has("regex", "regexp") { terms = append(terms, "2/template-literal-types") } terms = append(terms, "2/basic-types") case "react": if has("hook", "state", "effect", "memo") { terms = append(terms, "hooks") } terms = append(terms, "hooks") case "vue": if has("reactivity", "ref", "computed", "watch") { terms = append(terms, "essentials/reactivity-fundamentals") } terms = append(terms, "essentials/reactivity-fundamentals") case "nuxt": terms = append(terms, "directory-structure") case "docker": terms = append(terms, "compose") case "cloudflare": terms = append(terms, "workers") case "astro": terms = append(terms, "components") case "csharp": if has("regex", "regexp", "regular expression") { terms = append(terms, "regular-expressions") } terms = append(terms, "operators") case "kotlin": if has("regex", "regexp", "regular expression") { terms = append(terms, "regex") } terms = append(terms, "collections-overview") case "php": if has("regex", "regexp", "regular expression") { terms = append(terms, "function.preg-match.php") } terms = append(terms, "pcre") case "ruby": if has("regex", "regexp", "regular expression") { terms = append(terms, "Regexp") } terms = append(terms, "String") case "elixir": if has("regex", "regexp", "regular expression") { terms = append(terms, "Regex") } terms = append(terms, "String") case "nextjs": if has("routing", "route", "router") { terms = append(terms, "routing") } if has("data", "fetch", "server") { terms = append(terms, "data-fetching") } terms = append(terms, "routing") case "svelte": if has("store", "state") { terms = append(terms, "stores") } if has("kit", "routing", "load") { terms = append(terms, "kit") } terms = append(terms, "overview") case "angular": if has("http", "client", "request") { terms = append(terms, "http") } if has("routing", "route", "router") { terms = append(terms, "routing") } terms = append(terms, "components") case "remix": if has("route", "routing") { terms = append(terms, "routes") } if has("loader", "action", "data") { terms = append(terms, "loaders") } terms = append(terms, "routes") case "solid": added := false if has("router", "route", "routing") { terms = append(terms, "solid-router") added = true } if has("signal", "state") { terms = append(terms, "signals") added = true } if has("server", "ssr", "start") { terms = append(terms, "solid-start") added = true } if !added { terms = append(terms, "signals") } case "express": if has("middleware", "next", "request") { terms = append(terms, "middleware") } if has("routing", "route", "router") { terms = append(terms, "routing") } terms = append(terms, "middleware") } // Use a primary token from the query as a candidate if it looks useful. if primary := primaryQueryToken(question); primary != "" { terms = append(terms, primary) } return dedupeTerms(terms) } func primaryQueryToken(query string) string { stop := map[string]bool{ "how": true, "to": true, "do": true, "i": true, "in": true, "the": true, "a": true, "an": true, "best": true, "way": true, "for": true, "with": true, "and": true, "using": true, "what": true, "why": true, "when": true, "where": true, "which": true, "does": true, "is": true, "are": true, "can": true, "could": true, "should": true, "would": true, "need": true, "help": true, "please": true, "show": true, "example": true, "examples": true, "docs": true, "documentation": true, } for _, tok := range tokenize(query) { if len(tok) < 3 || stop[tok] { continue } if _, ok := normalizeLanguage(tok); ok { continue } return tok } return "" } func dedupeTerms(terms []string) []string { seen := make(map[string]bool) out := make([]string, 0, len(terms)) for _, term := range terms { term = strings.TrimSpace(term) key := strings.ToLower(term) if term == "" || seen[key] { continue } seen[key] = true out = append(out, term) } return out } func scoreDocument(query string, doc *scraper.Document) float64 { tokens := tokenize(query) title := strings.ToLower(doc.Title) content := strings.ToLower(doc.Content) docType := strings.ToLower(doc.Type) var score float64 = 0.1 for _, tok := range tokens { if strings.Contains(title, tok) { score += 3.0 } if strings.Contains(content, tok) { score += 1.0 } if urlContains(doc.URL, tok) { score += 0.8 } } if strings.Contains(docType, "function") || strings.Contains(docType, "method") { score += 0.3 } if strings.Contains(docType, "section") { score += 0.2 } return score } func urlContains(rawURL, token string) bool { u, err := url.Parse(rawURL) if err != nil { return strings.Contains(strings.ToLower(rawURL), token) } return strings.Contains(strings.ToLower(u.Path), token) || strings.Contains(strings.ToLower(u.Fragment), token) } func summarizeTopDocs(question string, docs []rankedDoc) string { if len(docs) == 0 { return "" } snippet := extractSnippet(docs[0].doc.Content, tokenize(question)) if snippet == "" || len(snippet) < 30 { return docs[0].doc.Title } return snippet } func ensureAPISlice(apis []string) []string { if apis == nil { return []string{} } return apis } func extractRecommendedAPI(docs []rankedDoc) []string { titlePattern := regexp.MustCompile(`\.(?:func|type|method)\s+([A-Za-z_][A-Za-z0-9_]*)`) titleDashPattern := regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_.]+)\s+-`) callPattern := regexp.MustCompile(`\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\(`) disallowed := map[string]bool{ "main": true, "len": true, "make": true, "new": true, "append": true, "copy": true, "print": true, "println": true, "panic": true, } seen := make(map[string]bool) var out []string pkgHints := make(map[string]bool) for _, rd := range docs { if u, err := url.Parse(rd.doc.URL); err == nil { base := strings.ToLower(path.Base(strings.Trim(u.Path, "/"))) if base != "" && base != "." && base != "/" { pkgHints[base] = true } } if m := titlePattern.FindStringSubmatch(rd.doc.Title); len(m) > 1 { name := m[1] if !seen[name] { seen[name] = true out = append(out, name) } } if m := titleDashPattern.FindStringSubmatch(rd.doc.Title); len(m) > 1 { name := m[1] if !seen[name] { seen[name] = true out = append(out, name) } } } // Titles are usually higher quality than free-form content call extraction. if len(out) >= 6 { return out[:6] } for _, rd := range docs { for _, m := range callPattern.FindAllStringSubmatch(rd.doc.Content, -1) { if len(m) < 2 { continue } name := m[1] if len(name) < 3 || seen[name] || disallowed[strings.ToLower(name)] { continue } if !strings.Contains(name, ".") { r := rune(name[0]) if r >= 'a' && r <= 'z' { // Skip local/internal-looking identifiers like validID. continue } } else { parts := strings.SplitN(name, ".", 2) if len(parts) != 2 { continue } if !pkgHints[strings.ToLower(parts[0])] { // Keep calls scoped to the fetched package/module docs. continue } } if strings.Count(name, ".") > 2 { continue } seen[name] = true out = append(out, name) if len(out) >= 8 { return out } } } return out } func extractExample(docs []rankedDoc) string { blockPattern := regexp.MustCompile("(?s)```[a-zA-Z0-9]*\\n(.*?)\\n```") var fallback string for _, rd := range docs { matches := blockPattern.FindAllStringSubmatch(rd.doc.Content, -1) for _, m := range matches { if len(m) < 2 { continue } example := strings.TrimSpace(m[1]) if example == "" { continue } if fallback == "" { fallback = example } // Prefer executable-looking snippets over plain signatures. if strings.Contains(example, "\n") || strings.Contains(example, "=") || strings.Contains(example, "{") { if len(example) > 500 { example = example[:500] } return sanitizeSnippet(example) } } } if fallback != "" { if len(fallback) > 500 { fallback = fallback[:500] } return sanitizeSnippet(fallback) } return "" } func buildAskSources(question string, docs []rankedDoc) []askSource { tokens := tokenize(question) out := make([]askSource, 0, len(docs)) for _, rd := range docs { out = append(out, askSource{ DocID: rd.doc.ID, Title: rd.doc.Title, URL: rd.doc.URL, Type: rd.doc.Type, SearchTerm: rd.searchTerm, Relevance: rd.score, Snippet: extractSnippet(rd.doc.Content, tokens), }) } return out } func computeConfidence(question string, docs []rankedDoc) float64 { if len(docs) == 0 { return 0 } tokCount := len(tokenize(question)) if tokCount == 0 { tokCount = 1 } maxPossible := float64(tokCount) * 4.0 if maxPossible < 1 { maxPossible = 1 } conf := docs[0].score / maxPossible if len(docs) > 1 && docs[0].score-docs[1].score < 0.5 { conf *= 0.92 } if conf < 0.1 { conf = 0.1 } if conf > 0.99 { conf = 0.99 } return conf } func tokenize(text string) []string { nonWord := regexp.MustCompile(`[^a-zA-Z0-9_/.-]+`) clean := strings.ToLower(nonWord.ReplaceAllString(text, " ")) raw := strings.Fields(clean) out := make([]string, 0, len(raw)) for _, tok := range raw { if len(tok) < 2 { continue } out = append(out, tok) } return out } func extractSnippet(content string, tokens []string) string { if content == "" { return "" } flat := strings.Join(strings.Fields(content), " ") if len(flat) > 7000 { flat = flat[:7000] } lower := strings.ToLower(flat) pos := -1 for _, tok := range tokens { if idx := strings.Index(lower, tok); idx >= 0 { pos = idx break } } if pos < 0 { if len(flat) > 220 { return sanitizeSnippet(flat[:220]) } return sanitizeSnippet(flat) } // Prefer sentence-ish boundaries around the match for cleaner snippets. start := strings.LastIndexAny(flat[:pos], ".!?") if start >= 0 { start++ } else { start = pos - 90 if start < 0 { start = 0 } } end := len(flat) if next := strings.IndexAny(flat[pos:], ".!?"); next >= 0 { end = pos + next + 1 } else { end = pos + 180 if end > len(flat) { end = len(flat) } } snippet := strings.TrimSpace(flat[start:end]) if len(snippet) > 260 { snippet = snippet[:260] } return sanitizeSnippet(snippet) } func sanitizeSnippet(s string) string { replacer := strings.NewReplacer("```", " ", "`", "", "ΒΆ", " ", "\u00a0", " ") s = replacer.Replace(s) s = strings.Join(strings.Fields(s), " ") return strings.TrimSpace(s) } func printAskText(resp askResponse) { fmt.Fprintf(rootCmd.OutOrStdout(), "Query: %s\n", resp.Query) fmt.Fprintf(rootCmd.OutOrStdout(), "Language: %s\n", resp.Language) fmt.Fprintf(rootCmd.OutOrStdout(), "Searched terms: %s\n", strings.Join(resp.SearchedTerms, ", ")) fmt.Fprintf(rootCmd.OutOrStdout(), "Retrieval: %s (local hits: %d, fallback fetched: %d)\n", resp.Retrieval.Mode, resp.Retrieval.LocalHits, resp.Retrieval.FallbackFetched) fmt.Fprintf(rootCmd.OutOrStdout(), "Confidence: %.2f\n\n", resp.Confidence) fmt.Fprintln(rootCmd.OutOrStdout(), "Summary:") fmt.Fprintln(rootCmd.OutOrStdout(), resp.Answer.Summary) fmt.Fprintln(rootCmd.OutOrStdout()) if len(resp.Answer.RecommendedAPI) > 0 { fmt.Fprintln(rootCmd.OutOrStdout(), "Recommended API:") for _, api := range resp.Answer.RecommendedAPI { fmt.Fprintf(rootCmd.OutOrStdout(), " - %s\n", api) } fmt.Fprintln(rootCmd.OutOrStdout()) } if resp.Answer.Example != "" { fmt.Fprintln(rootCmd.OutOrStdout(), "Example:") fmt.Fprintln(rootCmd.OutOrStdout(), resp.Answer.Example) fmt.Fprintln(rootCmd.OutOrStdout()) } fmt.Fprintln(rootCmd.OutOrStdout(), "Sources:") for _, src := range resp.Sources { fmt.Fprintf(rootCmd.OutOrStdout(), "- [%s] %s (%s)\n %s\n", src.Type, src.Title, src.SearchTerm, src.URL) } }