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.get_release_id().is_some()
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!(
114                "Skipping because it matches an ignored glob: {}",
115                entry_path.display()
116            );
117            continue;
118        }
119
120        info!("Processing file: {}", entry_path.display());
121        let source = MinifiedSourceFile::load(&entry_path)?;
122        let sourcemap_path = source.get_sourcemap_path(prefix)?;
123
124        let Some(path) = sourcemap_path else {
125            warn!(
126                "No sourcemap file found for file {}, skipping",
127                entry_path.display()
128            );
129            continue;
130        };
131
132        let sourcemap = SourceMapFile::load(&path).context(format!("reading {path:?}"))?;
133        pairs.push(SourcePair { source, sourcemap });
134    }
135
136    Ok(pairs)
137}
138
139impl TryInto<SymbolSetUpload> for SourcePair {
140    type Error = anyhow::Error;
141
142    fn try_into(self) -> Result<SymbolSetUpload> {
143        let chunk_id = self
144            .sourcemap
145            .get_chunk_id()
146            .ok_or_else(|| anyhow!("Chunk ID not found"))?;
147        let source_content = self.source.inner.content;
148        let sourcemap_content = serde_json::to_string(&self.sourcemap.inner.content)?;
149        let data = SourceAndMap {
150            minified_source: source_content,
151            sourcemap: sourcemap_content,
152        };
153
154        let data = write_symbol_data(data)?;
155
156        Ok(SymbolSetUpload {
157            chunk_id,
158            data,
159            release_id: self.sourcemap.get_release_id(),
160        })
161    }
162}