Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .claude/rules/c-sharp-code-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ Coding standards and style guidelines / preferences for C# files in this reposit

- Use file-scoped namespaces.
- Namespace names should follow the pattern: Company.Project.Module (e.g., Contoso.Sales.Reporting).
- Use `using` aliases to eliminate noise from verbose third-party type names:
```csharp
// ✅ alias at the top of the file — use the alias throughout
using GraphClient = Microsoft.Graph.GraphServiceClient;

// ❌ repeated fully-qualified name in every method signature
private static Task<...> FooAsync(Microsoft.Graph.GraphServiceClient client, ...) { }
```

## Folder Structure

Expand Down Expand Up @@ -89,6 +97,32 @@ This applies to all new code.
- Every `return` statement after a code block must be preceded by a blank line. `return` after an `if` must NOT be followed by a blank line or `{ return; }`.
- Name for **meaning**: `customerId` not `id`, `isExpired` not `flag`.
- Use builders for test setup / test data creation.
- Prefer a nullable accumulator parameter with `?? []` over an overload pair where one simply calls the other with an empty collection:
```csharp
// ❌ overload pair — the first adds nothing
private static Task<Result<List<T>, E>> GetPagesAsync(Client c, Page page, CancellationToken ct)
=> GetPagesAsync(c, page, [], ct);
private static Task<Result<List<T>, E>> GetPagesAsync(Client c, Page page, IReadOnlyCollection<T> acc, CancellationToken ct) { ... }

// ✅ single method with nullable accumulator
private static Task<Result<List<T>, E>> GetPagesAsync(Client c, Page page, IReadOnlyCollection<T>? acc, CancellationToken ct)
{
var items = (acc ?? []).Concat(GetItemsFromPage(page)).ToList();
// ...
}
```
- A private method whose entire body is a 1–2 combinator chain that forwards to another private method earns nothing — inline it at the call site:
```csharp
// ❌ wrapper adds a layer for no reason
private static Task<Result<List<DriveFolder>, GraphError>> GetDriveFoldersAsync(Client c, DriveFound d, RootFound r, CancellationToken ct)
=> GetFirstPageAsync(c, d, r, ct).BindAsync(page => GetAllPagesAsync(c, d, r, page, ct));

// ✅ inline at the call site
private static Task<Result<List<DriveFolder>, GraphError>> GetRootFoldersForDriveAsync(Client c, DriveFound d, CancellationToken ct)
=> GetRootAsync(c, d, ct)
.BindAsync(root => GetFirstPageAsync(c, d, root, ct)
.BindAsync(page => GetAllPagesAsync(c, d, root, page, null, ct)));
```

## Primitive Obsession

Expand Down
2 changes: 1 addition & 1 deletion .claude/rules/c-sharp-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ collection.ShouldBeEmpty();

```csharp
// ✅ test code — pattern matching is fine
var result = await repository.GetByIdAsync(id, ct);
var result = await repository.GetByIdAsync(id, cancellationToken);
result.ShouldBeOfType<Option<AccountEntity>.Some>();
var some = (Option<AccountEntity>.Some)result;
some.Value.Profile.Email.ShouldBe("test@example.com");
Expand Down
6 changes: 3 additions & 3 deletions .claude/rules/onedrive-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ string email = result.ClaimsPrincipal?.FindFirst("preferred_username")?.Value
## IAuthService contract

```csharp
Task<Result<AuthResult, AuthError>> SignInInteractiveAsync(CancellationToken ct = default);
Task<Result<AuthResult, AuthError>> AcquireTokenSilentAsync(string accountId, CancellationToken ct = default);
Task SignOutAsync(string accountId, CancellationToken ct = default);
Task<Result<AuthResult, AuthError>> SignInInteractiveAsync(CancellationToken cancellationToken = default);
Task<Result<AuthResult, AuthError>> AcquireTokenSilentAsync(string accountId, CancellationToken cancellationToken = default);
Task SignOutAsync(string accountId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<string>> GetCachedAccountIdsAsync();
```
12 changes: 6 additions & 6 deletions .claude/rules/onedrive-background.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ scheduler.StopSync(); // pause without disposing
scheduler.SetInterval(newInterval); // change interval mid-run

await scheduler.TriggerNowAsync(ct); // immediate one-shot sync (all accounts)
await scheduler.TriggerAccountAsync(accountId, ct); // sync a single account
await scheduler.TriggerAccountAsync(accountId, cancellationToken); // sync a single account
await scheduler.CancelAccountSyncAsync(accountId); // cancel in-flight sync for account
```

Expand All @@ -24,7 +24,7 @@ Use `Interlocked.Exchange` to prevent concurrent sync passes:
```csharp
private long _runningFlag;

private async Task RunSyncPassAsync(CancellationToken ct)
private async Task RunSyncPassAsync(CancellationToken cancellationToken)
{
if (Interlocked.Exchange(ref _runningFlag, 1) == 1) return;
try { /* sync all accounts */ }
Expand Down Expand Up @@ -76,15 +76,15 @@ event EventHandler<string>? SyncCompleted;
void StartSync(TimeSpan? interval = null);
void StopSync();
void SetInterval(TimeSpan interval);
Task TriggerNowAsync(CancellationToken ct = default);
Task TriggerAccountAsync(string accountId, CancellationToken ct = default);
Task TriggerAccountAsync(OneDriveAccount account, CancellationToken ct = default);
Task TriggerNowAsync(CancellationToken cancellationToken = default);
Task TriggerAccountAsync(string accountId, CancellationToken cancellationToken = default);
Task TriggerAccountAsync(OneDriveAccount account, CancellationToken cancellationToken = default);
Task CancelAccountSyncAsync(string accountId);
```

## CancellationToken rules

- **Every** public async method takes `CancellationToken ct = default` as the final parameter.
- **Every** public async method takes `CancellationToken cancellationToken = default` as the final parameter.
- Propagate `ct` to every downstream `await` — never pass `CancellationToken.None` unless you are deliberately starting unlinked work (e.g. timer-tick root, post-cancellation cleanup).
- Catch `OperationCanceledException` at the service boundary only — do not swallow it inside pipeline steps.
- Use `CancellationTokenSource.CreateLinkedTokenSource(ct)` when you need per-account cancellation composable with an outer token.
Expand Down
4 changes: 2 additions & 2 deletions .claude/rules/onedrive-di.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,10 @@ services.AddDbContextFactory<AppDbContext>(options =>
// Repository usage — one context per async operation
public class AccountRepository(IDbContextFactory<AppDbContext> dbFactory) : IAccountRepository
{
public async Task<Option<AccountEntity>> GetByIdAsync(AccountId id, CancellationToken ct)
public async Task<Option<AccountEntity>> GetByIdAsync(AccountId id, CancellationToken cancellationToken)
{
await using var context = await dbFactory.CreateDbContextAsync(ct);
var entity = await context.Accounts.FindAsync([id], ct);
var entity = await context.Accounts.FindAsync([id], cancellationToken);
return entity is null ? new None<AccountEntity>() : new Some<AccountEntity>(entity);
}
}
Expand Down
84 changes: 57 additions & 27 deletions .claude/rules/onedrive-graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ This repo uses `Microsoft.Graph` (SDK v5+) via a `GraphServiceClient` created pe
<PackageReference Include="Microsoft.Kiota.Abstractions" />
```

## Type alias

Always add a `using` alias at the top of any file that uses `GraphServiceClient` — the fully-qualified name is noisy in method signatures:

```csharp
using GraphClient = Microsoft.Graph.GraphServiceClient;
```

## Creating a Graph client

```csharp
Expand All @@ -19,7 +27,7 @@ public GraphServiceClient CreateClient(string accessToken)

private sealed class StaticAccessTokenProvider(string token) : IAccessTokenProvider
{
public Task<string> GetAuthorizationTokenAsync(Uri uri, ..., CancellationToken ct = default)
public Task<string> GetAuthorizationTokenAsync(Uri uri, ..., CancellationToken cancellationToken = default)
=> Task.FromResult(token);

public AllowedHostsValidator AllowedHostsValidator { get; } = new(["graph.microsoft.com"]);
Expand All @@ -45,17 +53,39 @@ _cache[accountId] = new DriveContext(new DriveId(drive.Id), root.Id);

## Getting root folders

Use a single `GetFolderPageAsync` helper with `string? nextLink` — `null` fetches the first page (with `Select` query params); non-null fetches subsequent pages via `.WithUrl` (URL already contains params). This replaces two near-identical methods:

```csharp
var response = await client
.Drives[driveId].Items[rootId].Children
.GetAsync(req => req.QueryParameters.Select = ["id", "name", "folder", "file", "size",
"lastModifiedDateTime", "parentReference", "eTag", "cTag",
"@microsoft.graph.downloadUrl"], ct);
private static async Task<Result<DriveItemCollectionResponse, GraphError>> GetFolderPageAsync(GraphClient client, DriveFound driveFound, RootFound rootFound, string? nextLink, CancellationToken cancellationToken)
{
var children = client.Drives[driveFound.Drive.Id].Items[rootFound.DriveItem.Id].Children;
var page = nextLink is null
? await children.GetAsync(req => req.QueryParameters.Select = ChildrenSelect, cancellationToken: cancellationToken).ConfigureAwait(false)
: await children.WithUrl(nextLink).GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false);

return page is { }
? new Ok<DriveItemCollectionResponse, GraphError>(page)
: new Fail<DriveItemCollectionResponse, GraphError>(GraphErrorFactory.Unexpected("Folder page was null."));
}
```

Recursive page accumulation uses `IReadOnlyCollection<T>?` with `?? []` — no overload pair needed:

```csharp
private static Task<Result<List<DriveFolder>, GraphError>> GetFoldersFromPagesAsync(GraphClient client, DriveFound driveFound, RootFound rootFound, DriveItemCollectionResponse page, IReadOnlyCollection<DriveFolder>? foldersSoFar, CancellationToken cancellationToken)
{
var folders = (foldersSoFar ?? []).Concat(GetFoldersFromPage(page)).ToList();

return page.OdataNextLink is null
? Task.FromResult<Result<List<DriveFolder>, GraphError>>(new Ok<List<DriveFolder>, GraphError>(folders))
: GetFolderPageAsync(client, driveFound, rootFound, page.OdataNextLink, cancellationToken)
.BindAsync(nextPage => GetFoldersFromPagesAsync(client, driveFound, rootFound, nextPage, folders, cancellationToken));
}
```

- Filter to `item.Folder is not null` to get folders only.
- **Always** paginate: loop on `OdataNextLink` using `.WithUrl(nextLink).GetAsync(ct)`.
- Return `DriveFolder(Id, Name, ParentId)` records ordered by name.
- Pass `null` for `foldersSoFar` on the initial call; recursive calls pass the accumulated list.
- Return `DriveFolder(Id, Name, ParentId)` records.

## Getting child folders (lazy folder tree)

Expand All @@ -66,7 +96,7 @@ var result = await client
{
req.QueryParameters.Select = ["id", "name", "folder", "parentReference"];
req.QueryParameters.Top = 100;
}, ct);
}, cancellationToken);
```

- Same pagination loop as root folders.
Expand All @@ -77,7 +107,7 @@ var result = await client
```csharp
var drive = await client
.Drives[driveId]
.GetAsync(req => req.QueryParameters.Select = ["quota"], ct);
.GetAsync(req => req.QueryParameters.Select = ["quota"], cancellationToken);
long total = drive?.Quota?.Total ?? 0L;
long used = drive?.Quota?.Used ?? 0L;
```
Expand All @@ -88,12 +118,12 @@ Recursively enumerates a folder subtree. Use a `HashSet<string>` of visited IDs

```csharp
static async Task EnumerateSubFolderAsync(GraphServiceClient client, DriveId driveId, string parentId,
string relativePath, List<DeltaItem> items, HashSet<string> visited, CancellationToken ct)
string relativePath, List<DeltaItem> items, HashSet<string> visited, CancellationToken cancellationToken)
{
if (!visited.Add(parentId)) return; // cycle guard

var page = await client.Drives[driveId.Value].Items[parentId].Children
.GetAsync(req => req.QueryParameters.Select = _childrenSelect, ct);
.GetAsync(req => req.QueryParameters.Select = _childrenSelect, cancellationToken);

while (page?.Value is not null)
{
Expand All @@ -102,11 +132,11 @@ static async Task EnumerateSubFolderAsync(GraphServiceClient client, DriveId dri
string itemPath = BuildRelativePath(relativePath, item);
items.Add(MapToDeltaItem(item, itemPath));
if (item.Folder is not null && item.Id is not null)
await EnumerateSubFolderAsync(client, driveId, item.Id, itemPath, items, visited, ct);
await EnumerateSubFolderAsync(client, driveId, item.Id, itemPath, items, visited, cancellationToken);
}
if (page.OdataNextLink is null) break;
page = await client.Drives[driveId.Value].Items[parentId].Children
.WithUrl(page.OdataNextLink).GetAsync(ct: ct);
.WithUrl(page.OdataNextLink).GetAsync(ct: cancellationToken);
}
}
```
Expand All @@ -118,7 +148,7 @@ Returns `Result<List<DeltaItem>, GraphError>`.
```csharp
var item = await client
.Drives[driveId].Items[$"root:/{remotePath}"]
.GetAsync(req => req.QueryParameters.Select = ["id"], ct);
.GetAsync(req => req.QueryParameters.Select = ["id"], cancellationToken);
// Returns null on 404 — catch ApiException with ResponseStatusCode == 404
```

Expand All @@ -128,7 +158,7 @@ Select `@microsoft.graph.downloadUrl` and read from `item.AdditionalData`:

```csharp
var item = await client.Drives[driveId].Items[itemId]
.GetAsync(req => req.QueryParameters.Select = ["@microsoft.graph.downloadUrl"], ct);
.GetAsync(req => req.QueryParameters.Select = ["@microsoft.graph.downloadUrl"], cancellationToken);

if (!item.AdditionalData.TryGetValue("@microsoft.graph.downloadUrl", out var url) || url is null)
return new Result<string, GraphError>.Error(GraphErrorFactory.NotFound(itemId));
Expand All @@ -148,7 +178,7 @@ var session = await client
.Drives[driveId].Items[parentFolderId]
.ItemWithPath(remotePath)
.CreateUploadSession
.PostAsync(requestBody, ct);
.PostAsync(requestBody, cancellationToken);

// requestBody sets @microsoft.graph.conflictBehavior = "replace"
// and fileSystemInfo.lastModifiedDateTime from local file's LastWriteTimeUtc
Expand All @@ -161,7 +191,7 @@ See `onedrive-sync.md` for the full upload/retry protocol.
## Deleting an item

```csharp
await client.Drives[driveId].Items[itemId].DeleteAsync(ct: ct);
await client.Drives[driveId].Items[itemId].DeleteAsync(ct: cancellationToken);
```

Returns `Result<Unit, GraphError>`.
Expand All @@ -188,15 +218,15 @@ var downloadUrl = ExtractDownloadUrl(item); // Option<string>
## IGraphService contract

```csharp
Task<Result<DriveId, GraphError>> GetDriveIdAsync(string accountId, string accessToken, CancellationToken ct = default);
Task<Result<List<DriveFolder>, GraphError>> GetRootFoldersAsync(string accountId, string accessToken, CancellationToken ct = default);
Task<Result<List<DriveFolder>, GraphError>> GetChildFoldersAsync(string accessToken, DriveId driveId, string parentFolderId, CancellationToken ct = default);
Task<Result<(long Total, long Used), GraphError>> GetQuotaAsync(string accountId, string accessToken, CancellationToken ct = default);
Task<Result<List<DeltaItem>, GraphError>> EnumerateFolderAsync(string accessToken, DriveId driveId, string folderId, string remotePath, CancellationToken ct = default);
Task<Option<string>> GetFolderIdByPathAsync(string accessToken, DriveId driveId, string remotePath, CancellationToken ct = default);
Task<Result<string, GraphError>> GetDownloadUrlAsync(string accountId, string accessToken, string itemId, CancellationToken ct = default);
Task<Result<string, GraphError>> UploadFileAsync(string accountId, string accessToken, string localPath, string remotePath, string parentFolderId, CancellationToken ct = default);
Task<Result<Unit, GraphError>> DeleteItemAsync(string accountId, string accessToken, string itemId, CancellationToken ct = default);
Task<Result<DriveId, GraphError>> GetDriveIdAsync(string accountId, string accessToken, CancellationToken cancellationToken = default);
Task<Result<List<DriveFolder>, GraphError>> GetRootFoldersAsync(string accountId, string accessToken, CancellationToken cancellationToken = default);
Task<Result<List<DriveFolder>, GraphError>> GetChildFoldersAsync(string accessToken, DriveId driveId, string parentFolderId, CancellationToken cancellationToken = default);
Task<Result<(long Total, long Used), GraphError>> GetQuotaAsync(string accountId, string accessToken, CancellationToken cancellationToken = default);
Task<Result<List<DeltaItem>, GraphError>> EnumerateFolderAsync(string accessToken, DriveId driveId, string folderId, string remotePath, CancellationToken cancellationToken = default);
Task<Option<string>> GetFolderIdByPathAsync(string accessToken, DriveId driveId, string remotePath, CancellationToken cancellationToken = default);
Task<Result<string, GraphError>> GetDownloadUrlAsync(string accountId, string accessToken, string itemId, CancellationToken cancellationToken = default);
Task<Result<string, GraphError>> UploadFileAsync(string accountId, string accessToken, string localPath, string remotePath, string parentFolderId, CancellationToken cancellationToken = default);
Task<Result<Unit, GraphError>> DeleteItemAsync(string accountId, string accessToken, string itemId, CancellationToken cancellationToken = default);
void EvictCachedDriveContext(string accountId);
```

Expand Down
4 changes: 2 additions & 2 deletions .claude/rules/onedrive-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
/// Persists a new account and its initial sync configuration after the wizard completes.
/// Returns the finalised <see cref="OneDriveAccount"/> with all defaults applied.
/// </summary>
Task<Result<OneDriveAccount, PersistenceError>> CompleteOnboardingAsync(OneDriveAccount account, CancellationToken ct = default);
Task<Result<OneDriveAccount, PersistenceError>> CompleteOnboardingAsync(OneDriveAccount account, CancellationToken cancellationToken = default);
```

## Responsibilities (in order)
Expand Down Expand Up @@ -56,7 +56,7 @@ The wizard ViewModel raises `Completed` with the draft `OneDriveAccount`. The ho
```csharp
wizard.Completed += async (_, account) =>
{
await _onboardingService.CompleteOnboardingAsync(account, ct)
await _onboardingService.CompleteOnboardingAsync(account, cancellationToken)
.MatchAsync(
finalAccount => { /* add to accounts list, navigate away */ },
error => { HasError = true; ErrorMessage = error.Message; });
Expand Down
4 changes: 2 additions & 2 deletions .claude/rules/onedrive-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,11 @@ IFileClassificationRuleRepository / FileClassificationRuleRepository
All write methods return `Result<Unit, PersistenceError>`. Catch EF exceptions at the repository:

```csharp
public async Task<Result<Unit, PersistenceError>> UpsertAsync(AccountEntity entity, CancellationToken ct)
public async Task<Result<Unit, PersistenceError>> UpsertAsync(AccountEntity entity, CancellationToken cancellationToken)
{
try
{
var existing = await context.Accounts.FindAsync([entity.Id], ct);
var existing = await context.Accounts.FindAsync([entity.Id], cancellationToken);
if (existing is null)
context.Accounts.Add(entity);
else
Expand Down
Loading
Loading