posthog_cli/sourcemaps/
source_pairs.rs

1use std::path::PathBuf;
2
3use crate::{
4    api::symbol_sets::SymbolSetUpload,
5    sourcemaps::content::{MinifiedSourceFile, SourceMapFile},
6};
7use anyhow::{anyhow, bail, Context, Result};
8use globset::{Glob, GlobSetBuilder};
9use posthog_symbol_data::{write_symbol_data, SourceAndMap};
10use tracing::{info, warn};
11use walkdir::{DirEntry, WalkDir};
12
13// Source pairs are the fundamental unit of a frontend symbol set
14pub struct SourcePair {
15    pub source: MinifiedSourceFile,
16    pub sourcemap: SourceMapFile,
17}
18
19impl SourcePair {
20    pub fn has_chunk_id(&self) -> bool {
21        // Minified chunks are the source of truth for their ID's, not sourcemaps,
22        // because sometimes sourcemaps are shared across multiple chunks.
23        self.get_chunk_id().is_some()
24    }
25
26    pub fn get_chunk_id(&self) -> Option<String> {
27        self.source.get_chunk_id()
28    }
29
30    pub fn has_release_id(&self) -> bool {
31        self.sourcemap.has_release_id()
32    }
33
34    pub fn get_release_id(&self) -> Option<String> {
35        self.sourcemap.get_release_id()
36    }
37
38    pub fn remove_chunk_id(&mut self, chunk_id: String) -> Result<()> {
39        if self.get_chunk_id().as_ref() != Some(&chunk_id) {
40            return Err(anyhow!("Chunk ID mismatch"));
41        }
42        let adjustment = self.source.remove_chunk_id(chunk_id)?;
43        self.sourcemap.apply_adjustment(adjustment)?;
44        self.sourcemap.set_chunk_id(None);
45        Ok(())
46    }
47
48    pub fn update_chunk_id(
49        &mut self,
50        previous_chunk_id: String,
51        new_chunk_id: String,
52    ) -> Result<()> {
53        self.remove_chunk_id(previous_chunk_id)?;
54        self.add_chunk_id(new_chunk_id)?;
55        Ok(())
56    }
57
58    pub fn add_chunk_id(&mut self, chunk_id: String) -> Result<()> {
59        if self.has_chunk_id() {
60            return Err(anyhow!("Chunk ID already set"));
61        }
62
63        let adjustment = self.source.set_chunk_id(&chunk_id)?;
64        // In cases where sourcemaps are shared across multiple chunks,
65        // we should only apply the adjustment if the sourcemap doesn't
66        // have a chunk ID set (since otherwise, it's already been adjusted)
67        if self.sourcemap.get_chunk_id().is_none() {
68            self.sourcemap.apply_adjustment(adjustment)?;
69            self.sourcemap.set_chunk_id(Some(chunk_id));
70        }
71        Ok(())
72    }
73
74    pub fn set_release_id(&mut self, release_id: Option<String>) {
75        self.sourcemap.set_release_id(release_id);
76    }
77
78    pub fn save(&self) -> Result<()> {
79        self.source.save()?;
80        self.sourcemap.save()?;
81        Ok(())
82    }
83}
84
85pub fn read_pairs(
86    directory: &PathBuf,
87    ignore_globs: &[String],
88    matcher: impl Fn(&DirEntry) -> bool,
89    prefix: &Option<String>,
90) -> Result<Vec<SourcePair>> {
91    // Make sure the directory exists
92    if !directory.exists() {
93        bail!("Directory does not exist");
94    }
95
96    let mut builder = GlobSetBuilder::new();
97    for glob in ignore_globs {
98        builder.add(Glob::new(glob)?);
99    }
100    let set: globset::GlobSet = builder.build()?;
101
102    let mut pairs = Vec::new();
103
104    for entry_path in WalkDir::new(directory)
105        .into_iter()
106        .filter_map(|e| e.ok())
107        .filter(matcher)
108        .map(|e| e.path().canonicalize())
109    {
110        let entry_path = entry_path?;
111
112        if set.is_match(&entry_path) {
113            info!("skip [ignored]: {}", entry_path.display());
114            continue;
115        }
116
117        let source = MinifiedSourceFile::load(&entry_path)?;
118        let sourcemap_path = source.get_sourcemap_path(prefix)?;
119
120        let Some(path) = sourcemap_path else {
121            warn!("skip [no sourcemap]: {}", entry_path.display());
122            continue;
123        };
124
125        info!("new pair: {}", entry_path.display());
126        let sourcemap = SourceMapFile::load(&path).context(format!("reading {path:?}"))?;
127        pairs.push(SourcePair { source, sourcemap });
128    }
129
130    info!("found {} pairs", pairs.len());
131
132    Ok(pairs)
133}
134
135impl TryInto<SymbolSetUpload> for SourcePair {
136    type Error = anyhow::Error;
137
138    fn try_into(self) -> Result<SymbolSetUpload> {
139        let chunk_id = self
140            .get_chunk_id()
141            .ok_or_else(|| anyhow!("Chunk ID not found"))?;
142        let source_content = self.source.inner.content;
143        let sourcemap_content = serde_json::to_string(&self.sourcemap.inner.content)?;
144        let data = SourceAndMap {
145            minified_source: source_content,
146            sourcemap: sourcemap_content,
147        };
148
149        let data = write_symbol_data(data)?;
150
151        Ok(SymbolSetUpload {
152            chunk_id,
153            data,
154            release_id: self.sourcemap.get_release_id(),
155        })
156    }
157}