Skip to main content

chub_cli/commands/
feedback.rs

1use clap::Args;
2use owo_colors::OwoColorize;
3
4use chub_core::identity::get_or_create_client_id;
5use chub_core::registry::{get_entry, MergedRegistry};
6use chub_core::telemetry::{
7    get_telemetry_url, is_feedback_enabled, is_telemetry_enabled, send_feedback, FeedbackOpts,
8    VALID_LABELS,
9};
10
11use crate::output;
12
13#[derive(Args)]
14pub struct FeedbackArgs {
15    /// Entry ID
16    id: Option<String>,
17
18    /// Rating: up or down
19    rating: Option<String>,
20
21    /// Optional comment
22    comment: Option<String>,
23
24    /// Explicit type: doc or skill
25    #[arg(long, name = "type")]
26    entry_type: Option<String>,
27
28    /// Language variant of the doc
29    #[arg(long)]
30    lang: Option<String>,
31
32    /// Version of the doc
33    #[arg(long)]
34    doc_version: Option<String>,
35
36    /// Specific file within the entry
37    #[arg(long)]
38    file: Option<String>,
39
40    /// Feedback label (repeatable)
41    #[arg(long = "label")]
42    labels: Vec<String>,
43
44    /// AI coding tool name
45    #[arg(long)]
46    agent: Option<String>,
47
48    /// LLM model name
49    #[arg(long)]
50    model: Option<String>,
51
52    /// Show feedback and telemetry status
53    #[arg(long)]
54    status: bool,
55}
56
57pub async fn run(args: FeedbackArgs, json: bool, merged: Option<&MergedRegistry>) {
58    if args.status {
59        run_status(json).await;
60        return;
61    }
62
63    let id = match &args.id {
64        Some(id) => id.clone(),
65        None => {
66            output::error(
67                "Missing required arguments: <id> and <rating>. Run: chub feedback <id> <up|down> [comment]",
68                json,
69            );
70            std::process::exit(1);
71        }
72    };
73
74    let rating = match &args.rating {
75        Some(r) => r.clone(),
76        None => {
77            output::error(
78                "Missing required arguments: <id> and <rating>. Run: chub feedback <id> <up|down> [comment]",
79                json,
80            );
81            std::process::exit(1);
82        }
83    };
84
85    if rating != "up" && rating != "down" {
86        output::error("Rating must be \"up\" or \"down\".", json);
87        std::process::exit(1);
88    }
89
90    if !is_feedback_enabled() {
91        if json {
92            println!(
93                "{}",
94                serde_json::json!({ "status": "skipped", "reason": "feedback_disabled" })
95            );
96        } else {
97            eprintln!(
98                "{}",
99                "Feedback is disabled. Enable with: feedback: true in ~/.chub/config.yaml".yellow()
100            );
101        }
102        return;
103    }
104
105    // Auto-detect entry type
106    let mut entry_type = args.entry_type.clone();
107    let mut doc_lang = args.lang.clone();
108    let mut doc_version = args.doc_version.clone();
109    let mut source = None;
110
111    if let Some(merged) = merged {
112        let result = get_entry(&id, merged);
113        if let Some(ref entry) = result.entry {
114            if entry_type.is_none() {
115                entry_type = Some(entry.entry_type.to_string());
116            }
117            source = Some(entry.source_name.clone());
118
119            if let Some(languages) = entry.languages() {
120                if doc_lang.is_none() && languages.len() == 1 {
121                    doc_lang = Some(languages[0].language.clone());
122                }
123                if doc_version.is_none() {
124                    let lang_obj = languages
125                        .iter()
126                        .find(|l| Some(&l.language) == doc_lang.as_ref())
127                        .or(languages.first());
128                    if let Some(l) = lang_obj {
129                        doc_version = Some(l.recommended_version.clone());
130                    }
131                }
132            }
133        }
134    }
135    let entry_type = entry_type.unwrap_or_else(|| "doc".to_string());
136
137    // Parse labels
138    let labels: Option<Vec<String>> = if args.labels.is_empty() {
139        None
140    } else {
141        let valid: Vec<String> = args
142            .labels
143            .iter()
144            .map(|l| l.trim().to_lowercase())
145            .filter(|l| VALID_LABELS.contains(&l.as_str()))
146            .collect();
147        if valid.is_empty() {
148            None
149        } else {
150            Some(valid)
151        }
152    };
153
154    let result = send_feedback(
155        &id,
156        &entry_type,
157        &rating,
158        FeedbackOpts {
159            comment: args.comment,
160            doc_lang: doc_lang.clone(),
161            doc_version: doc_version.clone(),
162            target_file: args.file.clone(),
163            labels,
164            agent: args.agent,
165            model: args.model,
166            cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
167            source,
168        },
169    )
170    .await;
171
172    if json {
173        println!(
174            "{}",
175            serde_json::to_string_pretty(&result).unwrap_or_default()
176        );
177    } else if result.status == "sent" {
178        let mut parts = vec![format!("Feedback recorded for {}", id).green().to_string()];
179        if let Some(ref lang) = doc_lang {
180            parts.push(format!("lang={}", lang).dimmed().to_string());
181        }
182        if let Some(ref ver) = doc_version {
183            parts.push(format!("version={}", ver).dimmed().to_string());
184        }
185        if let Some(ref file) = args.file {
186            parts.push(format!("file={}", file).dimmed().to_string());
187        }
188        eprintln!("{}", parts.join(" "));
189    } else if result.status == "error" {
190        let reason = result
191            .reason
192            .as_deref()
193            .or(result.code.map(|_| "HTTP error").as_ref().map(|s| *s))
194            .unwrap_or("unknown");
195        eprintln!("{}", format!("Failed to send feedback: {}", reason).red());
196    }
197}
198
199async fn run_status(json: bool) {
200    let feedback_enabled = is_feedback_enabled();
201    let telemetry_enabled = is_telemetry_enabled();
202
203    if json {
204        let client_id = get_or_create_client_id();
205        println!(
206            "{}",
207            serde_json::json!({
208                "feedback": feedback_enabled,
209                "telemetry": telemetry_enabled,
210                "client_id_prefix": client_id.as_deref().map(|s| &s[..s.len().min(8)]),
211                "endpoint": get_telemetry_url().unwrap_or_default(),
212                "valid_labels": VALID_LABELS,
213            })
214        );
215    } else {
216        let fb = if feedback_enabled {
217            "enabled".green().to_string()
218        } else {
219            "disabled".red().to_string()
220        };
221        let tl = if telemetry_enabled {
222            "enabled".green().to_string()
223        } else {
224            "disabled".red().to_string()
225        };
226        eprintln!("Feedback:  {}", fb);
227        eprintln!("Telemetry: {}", tl);
228        if let Some(cid) = get_or_create_client_id() {
229            let prefix = &cid[..cid.len().min(8)];
230            eprintln!("Client ID: {}...", prefix);
231        }
232        let endpoint = get_telemetry_url()
233            .unwrap_or_else(|| "(local only — set telemetry_url to enable remote)".to_string());
234        eprintln!("Endpoint:  {}", endpoint);
235        eprintln!("Labels:    {}", VALID_LABELS.join(", "));
236    }
237}