posthog_cli/experimental/
schema.rs

1use anyhow::{Context, Result};
2use inquire::{Select, Text};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::Path;
7use tracing::info;
8
9use crate::api::client::PHClient;
10use crate::invocation_context::context;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14enum Language {
15    TypeScript,
16}
17
18impl Language {
19    /// Get the language identifier used in API URLs
20    fn as_str(&self) -> &'static str {
21        match self {
22            Language::TypeScript => "typescript",
23        }
24    }
25
26    /// Get the display name for the language
27    fn display_name(&self) -> &'static str {
28        match self {
29            Language::TypeScript => "TypeScript",
30        }
31    }
32
33    /// Get the default output filename for this language
34    fn default_output_path(&self) -> &'static str {
35        match self {
36            Language::TypeScript => "posthog-typed.ts",
37        }
38    }
39
40    /// Get all available languages
41    fn all() -> Vec<Language> {
42        vec![Language::TypeScript]
43    }
44
45    /// Parse a language from a string identifier
46    fn from_str(s: &str) -> Option<Language> {
47        match s {
48            "typescript" => Some(Language::TypeScript),
49            _ => None,
50        }
51    }
52}
53
54#[derive(Debug, Serialize, Deserialize, Default)]
55struct SchemaConfig {
56    languages: HashMap<String, LanguageConfig>,
57}
58
59#[derive(Debug, Serialize, Deserialize, Clone)]
60struct LanguageConfig {
61    output_path: String,
62    schema_hash: String,
63    updated_at: String,
64    event_count: usize,
65}
66
67impl SchemaConfig {
68    /// Load config from posthog.json, returns empty config if file doesn't exist or is invalid
69    fn load() -> Self {
70        let content = fs::read_to_string("posthog.json").ok();
71        content
72            .and_then(|c| serde_json::from_str(&c).ok())
73            .unwrap_or_default()
74    }
75
76    /// Save config to posthog.json
77    fn save(&self) -> Result<()> {
78        let json =
79            serde_json::to_string_pretty(self).context("Failed to serialize schema config")?;
80        fs::write("posthog.json", json).context("Failed to write posthog.json")?;
81        Ok(())
82    }
83
84    /// Get language config for a specific language
85    fn get_language(&self, language: Language) -> Option<&LanguageConfig> {
86        self.languages.get(language.as_str())
87    }
88
89    /// Get output path for a language
90    fn get_output_path(&self, language: Language) -> Option<String> {
91        self.languages
92            .get(language.as_str())
93            .map(|l| l.output_path.clone())
94    }
95
96    /// Update language config, preserving other languages
97    fn update_language(
98        &mut self,
99        language: Language,
100        output_path: String,
101        schema_hash: String,
102        event_count: usize,
103    ) {
104        use chrono::Utc;
105
106        self.languages.insert(
107            language.as_str().to_string(),
108            LanguageConfig {
109                output_path,
110                schema_hash,
111                updated_at: Utc::now().to_rfc3339(),
112                event_count,
113            },
114        );
115    }
116}
117
118#[derive(Debug, Deserialize)]
119struct DefinitionsResponse {
120    content: String,
121    event_count: usize,
122    schema_hash: String,
123}
124
125pub fn pull(_host: Option<String>, output_override: Option<String>) -> Result<()> {
126    // Select language
127    let language = select_language()?;
128
129    info!(
130        "Fetching {} definitions from PostHog...",
131        language.display_name()
132    );
133
134    // Get PH client
135    let client = &context().client;
136
137    // Determine output path
138    let output_path = determine_output_path(language, output_override)?;
139
140    // Fetch definitions from the server
141    let response = fetch_definitions(client, language)?;
142
143    info!(
144        "āœ“ Fetched {} definitions for {} events",
145        language.display_name(),
146        response.event_count
147    );
148
149    // Check if schema has changed for this language
150    let config = SchemaConfig::load();
151    if let Some(lang_config) = config.get_language(language) {
152        if lang_config.schema_hash == response.schema_hash {
153            info!(
154                "Schema unchanged for {} (hash: {})",
155                language.as_str(),
156                response.schema_hash
157            );
158            println!(
159                "\nāœ“ {} schema is already up to date!",
160                language.display_name()
161            );
162            println!("  No changes detected - skipping file write.");
163            return Ok(());
164        }
165    }
166
167    // Write TypeScript definitions to file
168    info!("Writing {}...", output_path);
169
170    // Create parent directories if they don't exist
171    if let Some(parent) = Path::new(&output_path).parent() {
172        if !parent.as_os_str().is_empty() {
173            fs::create_dir_all(parent)
174                .context(format!("Failed to create directory {}", parent.display()))?;
175        }
176    }
177
178    fs::write(&output_path, &response.content).context(format!("Failed to write {output_path}"))?;
179    info!("āœ“ Generated {}", output_path);
180
181    // Update schema configuration for this language
182    info!("Updating posthog.json...");
183    let mut config = SchemaConfig::load();
184    config.update_language(
185        language,
186        output_path.clone(),
187        response.schema_hash,
188        response.event_count,
189    );
190    config.save()?;
191    info!("āœ“ Updated posthog.json");
192
193    println!("\nāœ“ Schema sync complete!");
194    println!("\nNext steps:");
195    println!("  1. Import PostHog from your generated module:");
196    println!("     import posthog from './{output_path}'");
197    println!("  2. Use typed events with autocomplete and type safety:");
198    println!("     posthog.captureTyped('event_name', {{ property: 'value' }})");
199    println!("  3. Or use regular capture() for flexibility:");
200    println!("     posthog.capture('dynamic_event', {{ any: 'data' }})");
201    println!();
202
203    Ok(())
204}
205
206fn determine_output_path(language: Language, output_override: Option<String>) -> Result<String> {
207    // If CLI override is provided, use it (and normalize it)
208    if let Some(path) = output_override {
209        return Ok(normalize_output_path(&path, language));
210    }
211
212    // Check if posthog.json exists and has an output_path for this language
213    let config = SchemaConfig::load();
214    if let Some(path) = config.get_output_path(language) {
215        return Ok(path);
216    }
217
218    // Prompt user for output path
219    let default_filename = language.default_output_path();
220    let current_dir = std::env::current_dir()
221        .ok()
222        .and_then(|p| p.to_str().map(String::from))
223        .unwrap_or_else(|| ".".to_string());
224
225    let help_message = format!(
226        "Your app will import PostHog from this file, so it should be accessible \
227         throughout your codebase (e.g., src/lib/, app/lib/, or your project root). \
228         This path will be saved in posthog.json and can be changed later. \
229         Current directory: {current_dir}"
230    );
231
232    let path = Text::new(&format!(
233        "Where should we save the {} typed PostHog module?",
234        language.display_name()
235    ))
236    .with_default(default_filename)
237    .with_help_message(&help_message)
238    .prompt()
239    .unwrap_or(default_filename.to_string());
240
241    Ok(normalize_output_path(&path, language))
242}
243
244fn normalize_output_path(path: &str, language: Language) -> String {
245    let path_obj = Path::new(path);
246
247    // If it's a directory (existing or ends with slash), append default filename
248    let should_append_filename =
249        (path_obj.exists() && path_obj.is_dir()) || path.ends_with('/') || path.ends_with('\\');
250
251    if should_append_filename {
252        path_obj
253            .join(language.default_output_path())
254            .to_string_lossy()
255            .into_owned()
256    } else {
257        path.to_string()
258    }
259}
260
261pub fn status() -> Result<()> {
262    // Check authentication
263    println!("\nPostHog Schema Sync Status\n");
264
265    println!("Authentication:");
266    let config = context().config.clone();
267    println!("  āœ“ Authenticated");
268    println!("  Host: {}", config.host);
269    println!("  Project ID: {}", config.env_id);
270    let masked_token = format!(
271        "{}****{}",
272        &config.api_key[..4],
273        &config.api_key[config.api_key.len() - 4..]
274    );
275    println!("  Token: {masked_token}");
276
277    println!();
278
279    // Check schema status
280    println!("Schema:");
281    let config = SchemaConfig::load();
282
283    if config.languages.is_empty() {
284        println!("  āœ— No schemas synced");
285        println!("  Run: posthog-cli exp schema pull");
286    } else {
287        println!("  āœ“ Schemas synced\n");
288
289        for (language_str, lang_config) in &config.languages {
290            // Parse language to get display name, fallback to raw string if unknown
291            let display = Language::from_str(language_str)
292                .map(|l| l.display_name())
293                .unwrap_or(language_str.as_str());
294
295            println!("  {display}:");
296            println!("    Hash: {}", lang_config.schema_hash);
297            println!("    Updated: {}", lang_config.updated_at);
298            println!("    Events: {}", lang_config.event_count);
299
300            if Path::new(&lang_config.output_path).exists() {
301                println!("    File: āœ“ {}", lang_config.output_path);
302            } else {
303                println!("    File: ! {} (missing)", lang_config.output_path);
304            }
305            println!();
306        }
307    }
308
309    println!();
310
311    Ok(())
312}
313
314fn fetch_definitions(client: &PHClient, language: Language) -> Result<DefinitionsResponse> {
315    let url = format!(
316        "/api/projects/{}/event_definitions/{}/",
317        client.get_env_id(),
318        language.as_str()
319    );
320
321    let response = client.get(&url)?.send().context(format!(
322        "Failed to fetch {} definitions",
323        language.display_name()
324    ))?;
325
326    if !response.status().is_success() {
327        return Err(anyhow::anyhow!(
328            "Failed to fetch {} definitions: HTTP {}",
329            language.display_name(),
330            response.status()
331        ));
332    }
333
334    let json: DefinitionsResponse = response.json().context(format!(
335        "Failed to parse {} definitions response",
336        language.display_name()
337    ))?;
338
339    Ok(json)
340}
341
342fn select_language() -> Result<Language> {
343    let languages = Language::all();
344
345    if languages.len() == 1 {
346        return Ok(languages[0]);
347    }
348
349    let language_strs: Vec<&str> = languages.iter().map(|l| l.display_name()).collect();
350    let selected = Select::new("Which language would you like to download?", language_strs)
351        .prompt()
352        .context("Failed to select language")?;
353
354    // Find the language that matches the selected display name
355    languages
356        .into_iter()
357        .find(|l| l.display_name() == selected)
358        .ok_or_else(|| anyhow::anyhow!("Invalid language selection"))
359}