Enforce Rust and Swift ownership boundary

This commit is contained in:
Georg Bauer
2026-07-31 14:10:00 +02:00
parent 6eb27537e8
commit a2615d5d83
10 changed files with 576 additions and 190 deletions

View File

@@ -80,6 +80,12 @@ pub struct IssueFilter {
pub labels: BTreeSet<String>,
}
impl IssueFilter {
pub fn is_active(&self, status: &str) -> bool {
status != "open" || self != &Self::default()
}
}
#[derive(Clone)]
pub struct RepositoryData {
pub owner: String,
@@ -132,3 +138,21 @@ pub struct PullDetails {
pub fn open_status() -> String {
"open".into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issue_filter_default_state_is_owned_by_rust() {
assert!(!IssueFilter::default().is_active("open"));
assert!(IssueFilter::default().is_active("closed"));
assert!(
IssueFilter {
milestone: "v1".into(),
..Default::default()
}
.is_active("open")
);
}
}

View File

@@ -259,6 +259,26 @@ impl GotchaCore {
Ok(issue_filter_options(&labels, &milestones, &filter))
}
pub fn issue_filters_active(
&self,
owner: String,
repository: String,
) -> Result<bool, GotchaError> {
let server = self.server()?;
let state = self.state.lock().unwrap();
let filter = state
.preferences
.issue_filters
.get(&repository_key(
&server.url,
owner.trim(),
repository.trim(),
))
.cloned()
.unwrap_or_default();
Ok(filter.is_active(&state.preferences.issue_status))
}
pub fn set_issue_filters(
&self,
owner: String,
@@ -381,8 +401,9 @@ impl GotchaCore {
owner: String,
repository: String,
path: String,
) -> Result<Vec<u8>, GotchaError> {
Ok(load_repository_file(&self.server()?, &owner, &repository, &path).await?)
) -> Result<RepositoryFilePage, GotchaError> {
let data = load_repository_file(&self.server()?, &owner, &repository, &path).await?;
Ok(repository_file_page(&path, data))
}
pub async fn commit_files(

View File

@@ -71,6 +71,7 @@ pub struct IssuePage {
pub struct IssueFilterOptions {
pub milestones: Vec<String>,
pub labels: Vec<String>,
pub unavailable_labels: Vec<String>,
pub selected_milestone: String,
pub selected_labels: Vec<String>,
}
@@ -81,8 +82,9 @@ pub struct MilestoneRow {
pub title: String,
pub description: String,
pub meta: String,
pub open_issues: i64,
pub closed_issues: i64,
pub has_issues: bool,
pub progress: f64,
pub progress_accessibility: String,
}
#[derive(Clone, uniffi::Record)]
@@ -105,8 +107,7 @@ pub struct PullPage {
pub struct CommitRow {
pub sha: String,
pub title: String,
pub meta: String,
pub refs: String,
pub detail: String,
pub top_lanes: Vec<u32>,
pub bottom_lanes: Vec<u32>,
pub node_lane: Option<u32>,
@@ -134,6 +135,15 @@ pub struct RepositoryContentRow {
pub size: i64,
}
#[derive(Clone, uniffi::Record)]
pub struct RepositoryFilePage {
pub name: String,
pub kind: String,
pub language: String,
pub text: String,
pub data: Vec<u8>,
}
#[derive(Clone, uniffi::Record)]
pub struct DiffLine {
pub old_number: String,
@@ -164,17 +174,16 @@ pub struct ActivityRow {
#[derive(Clone, uniffi::Record)]
pub struct HeatCell {
pub week: u32,
pub day: u32,
pub level: u32,
pub timestamp: i64,
pub contributions: i64,
}
#[derive(Clone, uniffi::Record)]
pub struct HomePage {
pub server_name: String,
pub activities: Vec<ActivityRow>,
pub issue_activities: Vec<ActivityRow>,
pub pull_activities: Vec<ActivityRow>,
pub heat_cells: Vec<HeatCell>,
pub contribution_count: i64,
}
@@ -237,17 +246,28 @@ pub fn issue_filter_options(
.iter()
.filter_map(|label| label.name.clone())
.collect();
let unavailable_labels = filter
.labels
.iter()
.filter(|label| !labels.contains(label))
.cloned()
.collect();
labels.extend(filter.labels.iter().cloned());
labels.sort_by_key(|label| label.to_lowercase());
labels.dedup();
let mut milestones: Vec<_> = milestones
.iter()
.filter_map(|milestone| milestone.title.clone())
.collect();
if !filter.milestone.is_empty() {
milestones.push(filter.milestone.clone());
}
milestones.sort_by_key(|milestone| milestone.to_lowercase());
milestones.dedup();
IssueFilterOptions {
milestones,
labels,
unavailable_labels,
selected_milestone: filter.milestone.clone(),
selected_labels: filter.labels.iter().cloned().collect(),
}
@@ -299,10 +319,12 @@ pub fn pull_rows(pulls: &[models::Issue]) -> Vec<PullRow> {
number: pull.number.unwrap_or_default(),
owner: repository.owner.clone()?,
repository: repository.name.clone()?,
title: pull
.title
.clone()
.unwrap_or_else(|| "Untitled pull request".into()),
title: format!(
"{} #{}\n{}",
repository.name.as_deref()?,
pull.number.unwrap_or_default(),
pull.title.as_deref().unwrap_or("Untitled pull request")
),
summary: pull
.body
.as_deref()
@@ -367,8 +389,16 @@ pub fn commit_page(
.map(|message| message.lines().next().unwrap_or(message))
.unwrap_or("Commit")
.into(),
meta: format!("{author} · {}", compact_date(date)),
refs: row.refs.join(" · "),
detail: if row.refs.is_empty() {
format!("{author} · {}\n{}", compact_date(date), short_sha(sha))
} else {
format!(
"{}\n{author} · {} · {}",
row.refs.join(" · "),
compact_date(date),
short_sha(sha)
)
},
top_lanes: indices(&row.top_lanes),
bottom_lanes: indices(&row.bottom_lanes),
node_lane: row.node_lane.map(|lane| lane as u32),
@@ -488,6 +518,66 @@ pub fn repository_content_rows(
rows
}
pub fn repository_file_page(path: &str, data: Vec<u8>) -> RepositoryFilePage {
let name = path.rsplit('/').next().unwrap_or("Preview").to_string();
let extension = path.rsplit('.').next().unwrap_or_default().to_lowercase();
let language = source_language(path).unwrap_or_default().to_string();
let markdown = matches!(extension.as_str(), "md" | "markdown" | "mdown" | "mkd");
let media = matches!(
extension.as_str(),
"apng"
| "avi"
| "bmp"
| "flac"
| "gif"
| "heic"
| "heif"
| "jpeg"
| "jpg"
| "m4a"
| "m4v"
| "mkv"
| "mov"
| "mp3"
| "mp4"
| "mpeg"
| "mpg"
| "ogg"
| "pdf"
| "png"
| "tif"
| "tiff"
| "wav"
| "webm"
| "webp"
);
if media {
return RepositoryFilePage {
name,
kind: "preview".into(),
language,
text: String::new(),
data,
};
}
match String::from_utf8(data) {
Ok(text) => RepositoryFilePage {
name,
kind: if markdown { "markdown" } else { "source" }.into(),
language,
text,
data: Vec::new(),
},
Err(error) => RepositoryFilePage {
name,
kind: "preview".into(),
language,
text: String::new(),
data: error.into_bytes(),
},
}
}
pub fn diff_page(path: &str, diff: crate::diff::Parsed) -> DiffPage {
DiffPage {
title: path.rsplit('/').next().unwrap_or(path).into(),
@@ -509,12 +599,51 @@ pub fn home_page(server_name: String, home: HomeData) -> HomePage {
let (heat_cells, contribution_count) = heat_cells(&home.heatmap);
HomePage {
server_name,
issue_activities: home
.activities
.iter()
.filter(|activity| is_issue_activity(activity))
.map(activity_row)
.collect(),
pull_activities: home
.activities
.iter()
.filter(|activity| is_pull_activity(activity))
.map(activity_row)
.collect(),
activities: home.activities.iter().map(activity_row).collect(),
heat_cells,
contribution_count,
}
}
fn is_issue_activity(activity: &models::Activity) -> bool {
use models::activity::OpType;
matches!(
activity.op_type,
Some(OpType::CreateIssue | OpType::CloseIssue | OpType::ReopenIssue | OpType::CommentIssue)
)
}
fn is_pull_activity(activity: &models::Activity) -> bool {
use models::activity::OpType;
matches!(
activity.op_type,
Some(
OpType::CreatePullRequest
| OpType::MergePullRequest
| OpType::AutoMergePullRequest
| OpType::ClosePullRequest
| OpType::ReopenPullRequest
| OpType::CommentPull
| OpType::ApprovePullRequest
| OpType::RejectPullRequest
)
)
}
fn activity_row(activity: &models::Activity) -> ActivityRow {
use models::activity::OpType;
@@ -653,15 +782,20 @@ fn activity_detail(activity: &models::Activity) -> String {
}
fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) {
const DAYS: i64 = 9 * 31;
let latest = data
.iter()
.filter_map(|entry| entry.timestamp)
.max()
.unwrap_or(0)
/ 86_400;
let start = latest - DAYS + 1;
let mut counts = vec![0_i64; DAYS as usize];
let (year, month, _) = civil_from_days(latest);
let first_month = year * 12 + month - 1 - 8;
let start = days_from_civil(
first_month.div_euclid(12),
first_month.rem_euclid(12) + 1,
1,
);
let mut counts = vec![0_i64; (latest - start + 1) as usize];
for entry in data {
let day = entry.timestamp.unwrap_or_default() / 86_400;
if (start..=latest).contains(&day) {
@@ -674,20 +808,42 @@ fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) {
.into_iter()
.enumerate()
.map(|(index, count)| HeatCell {
week: (index / 7) as u32,
day: (index % 7) as u32,
level: if count == 0 || maximum == 0 {
0
} else {
((count * 4 + maximum - 1) / maximum).clamp(1, 4) as u32
},
timestamp: (start + index as i64) * 86_400,
contributions: count,
})
.collect();
(cells, contribution_count)
}
fn civil_from_days(days: i64) -> (i64, i64, i64) {
let days = days + 719_468;
let era = days.div_euclid(146_097);
let day_of_era = days - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
year += i64::from(month <= 2);
(year, month, day)
}
fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 {
year -= i64::from(month <= 2);
let era = year.div_euclid(400);
let year_of_era = year - era * 400;
let month_prime = month + if month > 2 { -3 } else { 9 };
let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era - 719_468
}
fn label_row(label: &models::Label) -> Option<LabelRow> {
let name = label.name.as_deref()?.trim();
let color = label.color.as_deref()?.trim().trim_start_matches('#');
@@ -777,6 +933,7 @@ fn issue_milestone(issue: &models::Issue) -> String {
fn milestone_row(milestone: &models::Milestone) -> MilestoneRow {
let open = milestone.open_issues.unwrap_or_default();
let closed = milestone.closed_issues.unwrap_or_default();
let total = open + closed;
let due = milestone
.due_on
.as_deref()
@@ -798,8 +955,68 @@ fn milestone_row(milestone: &models::Milestone) -> MilestoneRow {
milestone.state.as_deref().unwrap_or("unknown"),
open + closed
),
open_issues: open,
closed_issues: closed,
has_issues: total > 0,
progress: if total == 0 {
0.0
} else {
closed as f64 / total as f64
},
progress_accessibility: format!("{closed} closed, {open} open"),
}
}
fn short_sha(sha: &str) -> &str {
&sha[..sha.len().min(8)]
}
fn source_language(path: &str) -> Option<&'static str> {
let filename = path.rsplit('/').next()?.to_lowercase();
match filename.as_str() {
"cmakelists.txt" => return Some("cmake"),
"dockerfile" => return Some("dockerfile"),
"gemfile" | "podfile" => return Some("ruby"),
"makefile" => return Some("makefile"),
_ => {}
}
match filename.rsplit('.').next()? {
"asm" => Some("x86asm"),
"c" => Some("c"),
"cc" | "cpp" | "h" | "hpp" => Some("cpp"),
"clj" => Some("clojure"),
"cs" => Some("csharp"),
"css" => Some("css"),
"dart" => Some("dart"),
"ex" | "exs" => Some("elixir"),
"fs" => Some("fsharp"),
"go" => Some("go"),
"groovy" => Some("groovy"),
"hs" => Some("haskell"),
"html" | "plist" | "xml" => Some("xml"),
"java" => Some("java"),
"jl" => Some("julia"),
"js" | "jsx" => Some("javascript"),
"json" => Some("json"),
"kt" | "kts" => Some("kotlin"),
"lua" => Some("lua"),
"m" | "mm" => Some("objectivec"),
"md" | "markdown" | "mdown" | "mkd" => Some("markdown"),
"php" => Some("php"),
"pl" => Some("perl"),
"ps1" => Some("powershell"),
"py" => Some("python"),
"r" => Some("r"),
"rb" => Some("ruby"),
"rs" => Some("rust"),
"scala" => Some("scala"),
"scss" => Some("scss"),
"sh" => Some("bash"),
"sql" => Some("sql"),
"swift" => Some("swift"),
"toml" => Some("ini"),
"ts" | "tsx" => Some("typescript"),
"txt" => Some("plaintext"),
"yaml" | "yml" => Some("yaml"),
_ => None,
}
}
@@ -853,24 +1070,19 @@ mod tests {
fn maps_heat_levels_and_labels() {
let heatmap = vec![
models::UserHeatmapData {
timestamp: Some(100 * 86_400),
timestamp: Some(20_393 * 86_400),
contributions: Some(1),
},
models::UserHeatmapData {
timestamp: Some(101 * 86_400),
timestamp: Some(20_665 * 86_400),
contributions: Some(4),
},
];
let (cells, total) = heat_cells(&heatmap);
assert_eq!((cells.len(), total), (279, 5));
assert_eq!((cells.len(), total), (273, 5));
assert_eq!(
(
cells[277].level,
cells[277].timestamp,
cells[277].contributions,
cells[278].level,
),
(1, 100 * 86_400, 1, 4)
(cells[0].level, cells[0].timestamp, cells[272].level,),
(1, 20_393 * 86_400, 4)
);
let label = models::Label {
@@ -890,10 +1102,8 @@ mod tests {
..Default::default()
};
let milestone_row = milestone_rows(std::slice::from_ref(&milestone)).remove(0);
assert_eq!(
(milestone_row.open_issues, milestone_row.closed_issues),
(3, 2)
);
assert_eq!(milestone_row.progress, 0.4);
assert_eq!(milestone_row.progress_accessibility, "2 closed, 3 open");
let issue = models::Issue {
milestone: Some(Box::new(milestone)),
..Default::default()
@@ -901,6 +1111,33 @@ mod tests {
assert_eq!(issue_rows(&[issue])[0].milestone, "Version 1");
}
#[test]
fn categorizes_home_activity_before_crossing_the_bridge() {
use models::activity::OpType;
let page = home_page(
"Gitea".into(),
HomeData {
activities: [
OpType::CreateIssue,
OpType::CreatePullRequest,
OpType::CreateRepo,
]
.into_iter()
.map(|op_type| models::Activity {
op_type: Some(op_type),
..Default::default()
})
.collect(),
heatmap: Vec::new(),
},
);
assert_eq!(page.activities.len(), 3);
assert_eq!(page.issue_activities.len(), 1);
assert_eq!(page.pull_activities.len(), 1);
}
#[test]
fn issue_page_exposes_state() {
let page = issue_page(IssueDetails {
@@ -917,8 +1154,8 @@ mod tests {
#[test]
fn builds_sorted_issue_filter_options() {
let filter = IssueFilter {
milestone: "v2".into(),
labels: ["bug".into(), "critical".into()].into(),
milestone: "v3".into(),
labels: ["bug".into(), "retired".into()].into(),
};
let options = issue_filter_options(
&[
@@ -944,9 +1181,38 @@ mod tests {
&filter,
);
assert_eq!(options.labels, ["bug", "critical"]);
assert_eq!(options.milestones, ["v1", "v2"]);
assert_eq!(options.selected_labels, ["bug", "critical"]);
assert_eq!(options.selected_milestone, "v2");
assert_eq!(options.labels, ["bug", "critical", "retired"]);
assert_eq!(options.unavailable_labels, ["retired"]);
assert_eq!(options.milestones, ["v1", "v2", "v3"]);
assert_eq!(options.selected_labels, ["bug", "retired"]);
assert_eq!(options.selected_milestone, "v3");
}
#[test]
fn classifies_repository_files_in_rust() {
let markdown = repository_file_page("docs/README.md", b"# Hello".to_vec());
assert_eq!(
(
markdown.name.as_str(),
markdown.kind.as_str(),
markdown.language.as_str()
),
("README.md", "markdown", "markdown")
);
assert_eq!(markdown.text, "# Hello");
let source = repository_file_page("Sources/App.swift", b"import UIKit".to_vec());
assert_eq!(
(source.kind.as_str(), source.language.as_str()),
("source", "swift")
);
let preview = repository_file_page("image.png", vec![0, 159]);
assert_eq!(preview.kind, "preview");
assert_eq!(preview.data, [0, 159]);
for path in ["manual.pdf", "sound.mp3", "movie.mp4"] {
assert_eq!(repository_file_page(path, Vec::new()).kind, "preview");
}
}
}