use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, NaiveDate, Utc};
use git2::{Repository, Time};
use shiplog_ids::{EventId, RunId};
use shiplog_ports::{IngestOutput, Ingestor};
use shiplog_schema::coverage::{Completeness, CoverageManifest, CoverageSlice, TimeWindow};
use shiplog_schema::event::{
Actor, EventEnvelope, EventKind, EventPayload, PullRequestEvent, PullRequestState, RepoRef,
RepoVisibility, SourceRef, SourceSystem,
};
use shiplog_schema::freshness::{FreshnessStatus, SourceFreshness};
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct LocalGitIngestor {
pub repo_path: PathBuf,
pub since: NaiveDate,
pub until: NaiveDate,
pub author: Option<String>,
pub include_merges: bool,
}
impl LocalGitIngestor {
pub fn new(repo_path: impl AsRef<Path>, since: NaiveDate, until: NaiveDate) -> Self {
Self {
repo_path: repo_path.as_ref().to_path_buf(),
since,
until,
author: None,
include_merges: false,
}
}
pub fn with_author(mut self, author: impl Into<String>) -> Self {
self.author = Some(author.into());
self
}
pub fn with_merges(mut self, include: bool) -> Self {
self.include_merges = include;
self
}
fn open_repo(&self) -> Result<Repository> {
let path = &self.repo_path;
if !path.exists() {
return Err(anyhow!("Path does not exist: {}", path.display()));
}
Repository::open(path)
.with_context(|| format!("Failed to open git repository at {}", path.display()))
}
#[mutants::skip]
fn get_repo_name(&self, repo: &Repository) -> Result<String> {
if let Ok(remote) = repo.find_remote("origin")
&& let Some(url) = remote.url()
{
if let Some(name) = url.split('/').next_back() {
return Ok(name.trim_end_matches(".git").to_string());
}
}
self.repo_path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("Could not determine repository name"))
}
fn git_time_to_datetime(time: &Time) -> DateTime<Utc> {
DateTime::from_timestamp(time.seconds(), 0)
.unwrap_or_else(|| DateTime::from_timestamp(0, 0).unwrap())
}
fn is_in_date_range(&self, commit_time: &DateTime<Utc>) -> bool {
let commit_date = commit_time.date_naive();
commit_date >= self.since && commit_date <= self.until
}
fn matches_author(&self, commit: &git2::Commit) -> bool {
if let Some(ref author_email) = self.author {
let author = commit.author();
let email_matches = author
.email()
.map(|e| e.to_lowercase() == author_email.to_lowercase())
.unwrap_or(false);
let name_matches = author
.name()
.map(|n| n.to_lowercase() == author_email.to_lowercase())
.unwrap_or(false);
email_matches || name_matches
} else {
true
}
}
fn is_merge_commit(commit: &git2::Commit) -> bool {
commit.parent_count() > 1
}
#[mutants::skip]
fn commit_to_event(
&self,
commit: &git2::Commit,
repo_name: &str,
_run_id: &RunId,
) -> Result<EventEnvelope> {
let commit_time = Self::git_time_to_datetime(&commit.time());
let commit_hash = commit.id().to_string();
let title = commit.summary().unwrap_or("<no message>").to_string();
let author = commit.author();
let author_name = author.name().unwrap_or("Unknown").to_string();
let author_email = author.email().unwrap_or("").to_string();
let actor_login = if !author_email.is_empty() {
author_email.clone()
} else {
author_name.clone()
};
let event_id = EventId::from_parts(["local_git", &commit_hash]);
let source = SourceRef {
system: SourceSystem::LocalGit,
url: None,
opaque_id: Some(commit_hash),
};
let repo = RepoRef {
full_name: repo_name.to_string(),
html_url: None,
visibility: RepoVisibility::Unknown,
};
let actor = Actor {
login: actor_login,
id: None,
};
let payload = EventPayload::PullRequest(PullRequestEvent {
number: 0, title,
state: PullRequestState::Merged, created_at: commit_time,
merged_at: Some(commit_time),
additions: None,
deletions: None,
changed_files: None,
touched_paths_hint: vec![],
window: Some(TimeWindow {
since: self.since,
until: self.until,
}),
});
let links = vec![];
Ok(EventEnvelope {
id: event_id,
kind: EventKind::PullRequest,
occurred_at: commit_time,
actor,
repo,
payload,
tags: vec![],
links,
source,
})
}
#[mutants::skip]
fn collect_commits(&self, repo: &Repository, run_id: &RunId) -> Result<Vec<EventEnvelope>> {
let mut events = Vec::new();
let repo_name = self.get_repo_name(repo)?;
let head = repo.head().context("Failed to get HEAD reference")?;
let head_commit = head.peel_to_commit().context("Failed to peel to commit")?;
let mut revwalk = repo.revwalk().context("Failed to create revwalk")?;
revwalk
.push(head_commit.id())
.context("Failed to push HEAD to revwalk")?;
for commit_id in revwalk {
let commit_id = commit_id.context("Failed to get commit id")?;
let commit = repo
.find_commit(commit_id)
.context("Failed to find commit")?;
let commit_time = Self::git_time_to_datetime(&commit.time());
if commit_time.date_naive() < self.since {
break;
}
if !self.is_in_date_range(&commit_time) {
continue;
}
if !self.matches_author(&commit) {
continue;
}
if !self.include_merges && Self::is_merge_commit(&commit) {
continue;
}
match self.commit_to_event(&commit, &repo_name, run_id) {
Ok(event) => events.push(event),
Err(e) => {
eprintln!("Warning: Failed to convert commit to event: {}", e);
}
}
}
events.sort_by_key(|e| std::cmp::Reverse(e.occurred_at));
Ok(events)
}
}
impl Ingestor for LocalGitIngestor {
fn ingest(&self) -> Result<IngestOutput> {
if self.since >= self.until {
return Err(anyhow!("since must be < until"));
}
let repo = self.open_repo()?;
let run_id = RunId::now("shiplog");
let events = self.collect_commits(&repo, &run_id)?;
let coverage_slice = CoverageSlice {
window: TimeWindow {
since: self.since,
until: self.until,
},
query: format!("local_git:{}", self.repo_path.display()),
total_count: events.len() as u64,
fetched: events.len() as u64,
incomplete_results: Some(false),
notes: vec![],
};
let fetched_at = Utc::now();
let coverage = CoverageManifest {
run_id: run_id.clone(),
generated_at: fetched_at,
user: "local".to_string(),
window: TimeWindow {
since: self.since,
until: self.until,
},
mode: "local".to_string(),
sources: vec!["local_git".to_string()],
slices: vec![coverage_slice],
warnings: vec![],
completeness: Completeness::Complete,
};
let freshness = vec![SourceFreshness {
source: "local_git".to_string(),
status: FreshnessStatus::Fresh,
cache_hits: 0,
cache_misses: 0,
fetched_at: Some(fetched_at),
reason: None,
}];
Ok(IngestOutput {
events,
coverage,
freshness,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use git2::Signature;
use proptest::prelude::*;
use tempfile::TempDir;
fn create_test_repo() -> Result<(TempDir, Repository)> {
let dir = TempDir::new()?;
let repo = Repository::init(dir.path())?;
let mut config = repo.config()?;
config.set_str("user.name", "Test User")?;
config.set_str("user.email", "test@example.com")?;
let sig = repo.signature()?;
let mut index = repo.index()?;
let tree_id = index.write_tree()?;
{
let tree = repo.find_tree(tree_id)?;
let _oid = repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])?;
}
let tree_id = {
let oid = repo.head()?.peel_to_commit()?;
oid.tree_id()
};
{
let tree = repo.find_tree(tree_id)?;
let _oid = repo.commit(
Some("HEAD"),
&sig,
&sig,
"Second commit",
&tree,
&[&repo.head()?.peel_to_commit()?],
)?;
}
Ok((dir, repo))
}
fn create_multi_author_repo() -> Result<(TempDir, Repository)> {
let dir = TempDir::new()?;
let repo = Repository::init(dir.path())?;
let mut config = repo.config()?;
config.set_str("user.name", "Alice")?;
config.set_str("user.email", "alice@example.com")?;
let alice = Signature::now("Alice", "alice@example.com")?;
let bob = Signature::now("Bob", "bob@example.com")?;
let mut index = repo.index()?;
let tree_id = index.write_tree()?;
let c1 = {
let tree = repo.find_tree(tree_id)?;
repo.commit(Some("HEAD"), &alice, &alice, "Alice initial", &tree, &[])?
};
let c2 = {
let tree = repo.find_tree(tree_id)?;
let c1_commit = repo.find_commit(c1)?;
repo.commit(
Some("HEAD"),
&bob,
&bob,
"Bob feature work",
&tree,
&[&c1_commit],
)?
};
let c3 = {
let tree = repo.find_tree(tree_id)?;
let c2_commit = repo.find_commit(c2)?;
repo.commit(
Some("HEAD"),
&alice,
&alice,
"Alice second commit",
&tree,
&[&c2_commit],
)?
};
let branch_commit = {
let tree = repo.find_tree(tree_id)?;
let c3_commit = repo.find_commit(c3)?;
repo.commit(
None, &bob,
&bob,
"Bob branch commit",
&tree,
&[&c3_commit],
)?
};
{
let tree = repo.find_tree(tree_id)?;
let c3_commit = repo.find_commit(c3)?;
let branch_commit_obj = repo.find_commit(branch_commit)?;
let _merge = repo.commit(
Some("HEAD"),
&alice,
&alice,
"Merge branch into main",
&tree,
&[&c3_commit, &branch_commit_obj],
)?;
}
Ok((dir, repo))
}
#[test]
fn test_open_repo() {
let (_dir, repo) = create_test_repo().unwrap();
let repo_path = repo.path().parent().unwrap().to_path_buf();
let ingestor = LocalGitIngestor::new(
&repo_path,
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
);
let result = ingestor.open_repo();
assert!(result.is_ok());
}
#[test]
fn test_open_nonexistent_repo() {
let ingestor = LocalGitIngestor::new(
"/nonexistent/path",
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
);
let result = ingestor.open_repo();
assert!(result.is_err());
}
#[test]
fn test_git_time_to_datetime() {
let time = Time::new(1704067200, 0); let dt = LocalGitIngestor::git_time_to_datetime(&time);
assert_eq!(dt.timestamp(), 1704067200);
}
#[test]
fn test_is_in_date_range() {
let ingestor = LocalGitIngestor::new(
"/tmp",
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
);
let inside = DateTime::from_timestamp(1735689600, 0).unwrap(); let before = DateTime::from_timestamp(1733011200, 0).unwrap(); let after = DateTime::from_timestamp(1738368000, 0).unwrap();
assert!(!ingestor.is_in_date_range(&before));
assert!(ingestor.is_in_date_range(&inside));
assert!(!ingestor.is_in_date_range(&after));
}
#[test]
fn test_matches_author() {
let (_dir, repo) = create_test_repo().unwrap();
let head = repo.head().unwrap();
let commit = head.peel_to_commit().unwrap();
let ingestor = LocalGitIngestor::new(
"/tmp",
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
)
.with_author("test@example.com");
assert!(ingestor.matches_author(&commit));
let ingestor = LocalGitIngestor::new(
"/tmp",
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
)
.with_author("other@example.com");
assert!(!ingestor.matches_author(&commit));
}
#[test]
fn test_is_merge_commit() {
let (_dir, repo) = create_test_repo().unwrap();
let head = repo.head().unwrap();
let commit = head.peel_to_commit().unwrap();
assert!(!LocalGitIngestor::is_merge_commit(&commit));
}
#[test]
fn test_ingest() {
let (_dir, repo) = create_test_repo().unwrap();
let repo_path = repo.path().parent().unwrap().to_path_buf();
let ingestor = LocalGitIngestor::new(
&repo_path,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(),
);
let result = ingestor.ingest();
assert!(result.is_ok());
let output = result.unwrap();
assert!(!output.events.is_empty());
assert_eq!(output.coverage.slices.len(), 1);
assert_eq!(output.coverage.sources, vec!["local_git"]);
}
#[test]
fn test_ingest_with_author_filter() {
let (_dir, repo) = create_test_repo().unwrap();
let repo_path = repo.path().parent().unwrap().to_path_buf();
let ingestor = LocalGitIngestor::new(
&repo_path,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(),
)
.with_author("test@example.com");
let result = ingestor.ingest();
assert!(result.is_ok());
let output = result.unwrap();
assert!(!output.events.is_empty());
}
#[test]
fn test_ingest_invalid_date_range() {
let (_dir, repo) = create_test_repo().unwrap();
let repo_path = repo.path().parent().unwrap().to_path_buf();
let ingestor = LocalGitIngestor::new(
&repo_path,
NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
);
let result = ingestor.ingest();
assert!(result.is_err());
}
proptest! {
#[test]
fn git_time_to_datetime_always_valid(secs in 0i64..=4_102_444_800i64) {
let time = Time::new(secs, 0);
let dt = LocalGitIngestor::git_time_to_datetime(&time);
prop_assert_eq!(dt.timestamp(), secs);
}
#[test]
fn git_time_to_datetime_negative_yields_epoch(secs in i64::MIN..0i64) {
let time = Time::new(secs, 0);
let dt = LocalGitIngestor::git_time_to_datetime(&time);
prop_assert!(dt.timestamp() == secs || dt.timestamp() == 0);
}
#[test]
fn is_in_date_range_boundary_inclusive(
day_offset in 0u32..365,
) {
let since = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let until = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
let ingestor = LocalGitIngestor::new("/tmp", since, until);
let test_date = since + chrono::Duration::days(day_offset as i64);
let dt = test_date.and_hms_opt(0, 0, 0).unwrap().and_utc();
if test_date >= since && test_date <= until {
prop_assert!(ingestor.is_in_date_range(&dt));
} else {
prop_assert!(!ingestor.is_in_date_range(&dt));
}
}
#[test]
fn builder_preserves_author(author in "[a-z]+@[a-z]+\\.[a-z]+") {
let ingestor = LocalGitIngestor::new(
"/tmp",
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
)
.with_author(&author);
prop_assert_eq!(ingestor.author.as_deref(), Some(author.as_str()));
}
#[test]
fn builder_preserves_merges(flag in proptest::bool::ANY) {
let ingestor = LocalGitIngestor::new(
"/tmp",
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
)
.with_merges(flag);
prop_assert_eq!(ingestor.include_merges, flag);
}
}
#[test]
fn ingest_author_filter_isolates_single_author() {
let (_dir, repo) = create_multi_author_repo().unwrap();
let repo_path = repo.path().parent().unwrap().to_path_buf();
let alice_ingestor = LocalGitIngestor::new(
&repo_path,
NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(),
)
.with_author("alice@example.com");
let alice_out = alice_ingestor.ingest().unwrap();
let bob_ingestor = LocalGitIngestor::new(
&repo_path,
NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(),
)
.with_author("bob@example.com");
let bob_out = bob_ingestor.ingest().unwrap();
let all_ingestor = LocalGitIngestor::new(
&repo_path,
NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(),
)
.with_merges(true);
let all_out = all_ingestor.ingest().unwrap();
assert!(!alice_out.events.is_empty());
assert!(!bob_out.events.is_empty());
assert!(alice_out.events.len() + bob_out.events.len() <= all_out.events.len());
}
#[test]
fn author_matching_is_case_insensitive() {
let (_dir, repo) = create_test_repo().unwrap();
let head = repo.head().unwrap();
let commit = head.peel_to_commit().unwrap();
let upper = LocalGitIngestor::new(
"/tmp",
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
)
.with_author("TEST@EXAMPLE.COM");
assert!(upper.matches_author(&commit));
let mixed = LocalGitIngestor::new(
"/tmp",
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
)
.with_author("Test@Example.Com");
assert!(mixed.matches_author(&commit));
}
#[test]
fn author_matching_by_name() {
let (_dir, repo) = create_test_repo().unwrap();
let head = repo.head().unwrap();
let commit = head.peel_to_commit().unwrap();
let by_name = LocalGitIngestor::new(
"/tmp",
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
)
.with_author("Test User");
assert!(by_name.matches_author(&commit));
}
#[test]
fn no_author_filter_matches_all() {
let (_dir, repo) = create_test_repo().unwrap();
let head = repo.head().unwrap();
let commit = head.peel_to_commit().unwrap();
let ingestor = LocalGitIngestor::new(
"/tmp",
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
);
assert!(ingestor.matches_author(&commit));
}
#[test]
fn merge_commit_detected_in_multi_author_repo() {
let (_dir, repo) = create_multi_author_repo().unwrap();
let head = repo.head().unwrap();
let commit = head.peel_to_commit().unwrap();
assert!(LocalGitIngestor::is_merge_commit(&commit));
}
#[test]
fn ingest_excludes_merges_by_default() {
let (_dir, repo) = create_multi_author_repo().unwrap();
let repo_path = repo.path().parent().unwrap().to_path_buf();
let no_merge = LocalGitIngestor::new(
&repo_path,
NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(),
);
let with_merge = LocalGitIngestor::new(
&repo_path,
NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(),
)
.with_merges(true);
let no_merge_out = no_merge.ingest().unwrap();
let with_merge_out = with_merge.ingest().unwrap();
assert!(with_merge_out.events.len() > no_merge_out.events.len());
}
#[test]
fn ingest_narrow_date_range_filters_correctly() {
let (_dir, repo) = create_test_repo().unwrap();
let repo_path = repo.path().parent().unwrap().to_path_buf();
let ingestor = LocalGitIngestor::new(
&repo_path,
NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2000, 1, 2).unwrap(),
);
let output = ingestor.ingest().unwrap();
assert!(output.events.is_empty());
assert_eq!(output.coverage.slices[0].total_count, 0);
}
#[test]
fn ingest_nonexistent_author_yields_empty() {
let (_dir, repo) = create_test_repo().unwrap();
let repo_path = repo.path().parent().unwrap().to_path_buf();
let ingestor = LocalGitIngestor::new(
&repo_path,
NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(),
)
.with_author("nobody@nowhere.com");
let output = ingestor.ingest().unwrap();
assert!(output.events.is_empty());
}
#[test]
fn coverage_manifest_populated_correctly() {
let (_dir, repo) = create_test_repo().unwrap();
let repo_path = repo.path().parent().unwrap().to_path_buf();
let since = NaiveDate::from_ymd_opt(2020, 1, 1).unwrap();
let until = NaiveDate::from_ymd_opt(2030, 12, 31).unwrap();
let ingestor = LocalGitIngestor::new(&repo_path, since, until);
let output = ingestor.ingest().unwrap();
assert_eq!(output.coverage.window.since, since);
assert_eq!(output.coverage.window.until, until);
assert_eq!(output.coverage.user, "local");
assert_eq!(output.coverage.mode, "local");
assert_eq!(output.coverage.sources, vec!["local_git"]);
assert_eq!(output.coverage.slices.len(), 1);
let slice = &output.coverage.slices[0];
assert_eq!(slice.total_count, slice.fetched);
assert_eq!(slice.total_count, output.events.len() as u64);
assert_eq!(slice.incomplete_results, Some(false));
}
#[test]
fn events_sorted_newest_first() {
let (_dir, repo) = create_test_repo().unwrap();
let repo_path = repo.path().parent().unwrap().to_path_buf();
let ingestor = LocalGitIngestor::new(
&repo_path,
NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(),
);
let output = ingestor.ingest().unwrap();
for pair in output.events.windows(2) {
assert!(pair[0].occurred_at >= pair[1].occurred_at);
}
}
#[test]
fn all_events_have_local_git_source() {
let (_dir, repo) = create_test_repo().unwrap();
let repo_path = repo.path().parent().unwrap().to_path_buf();
let ingestor = LocalGitIngestor::new(
&repo_path,
NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(),
);
let output = ingestor.ingest().unwrap();
for event in &output.events {
assert_eq!(event.source.system, SourceSystem::LocalGit);
assert!(event.source.opaque_id.is_some());
assert_eq!(event.kind, EventKind::PullRequest);
}
}
#[test]
fn ingest_equal_dates_errors() {
let (_dir, repo) = create_test_repo().unwrap();
let repo_path = repo.path().parent().unwrap().to_path_buf();
let same_date = NaiveDate::from_ymd_opt(2025, 6, 15).unwrap();
let ingestor = LocalGitIngestor::new(&repo_path, same_date, same_date);
let err = ingestor.ingest().unwrap_err();
assert!(err.to_string().contains("since must be < until"));
}
#[test]
fn open_path_exists_but_not_a_repo() {
let dir = TempDir::new().unwrap();
let ingestor = LocalGitIngestor::new(
dir.path(),
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
);
let result = ingestor.open_repo();
let err = result.err().expect("expected an error");
assert!(err.to_string().contains("Failed to open git repository"));
}
#[test]
fn git_time_to_datetime_at_epoch() {
let time = Time::new(0, 0);
let dt = LocalGitIngestor::git_time_to_datetime(&time);
assert_eq!(dt.timestamp(), 0);
}
}