noz-cli/NozCli/Repl/ReplContext.cs
kryptomrx 0eebffe745 test: xUnit test suite and Forgejo CI pipeline
- 43 tests across SlugGuard, ReplContext navigation, NotesCache.TimeAgo
- SlugGuard moved to NozCli.Core.Security (shared between REPL and tests)
- Bug fix: FoldersInView() now returns only actual sub-folders, not note segments
  (previously cd tab-completion suggested note names as navigable folders)
- .forgejo/workflows/ci.yml: build + test on every push/PR
2026-05-16 23:53:40 +02:00

158 lines
5.7 KiB
C#

using NozCli.Core.Client;
using NozCli.Core.Config;
using NozCli.Core.Models;
namespace NozCli.Repl;
/// Shared mutable state across the REPL session.
/// Navigation model:
/// ~ → root, FoldersInView() returns distinct topics
/// ~/psychologie → topic view, notes filtered by topic
/// ~/biosysteme/subfolder → slug subfolder within a topic
public class ReplContext
{
public NozApiClient Api { get; }
public LocalConfig Config { get; }
public List<NoteInfo> NoteCache { get; set; } = [];
public bool Running { get; set; } = true;
// Navigation state
public string? CurrentTopic { get; set; } = null; // null = root
public string? CurrentSubPath { get; set; } = null; // sub-path within topic
public string CurrentPath =>
CurrentTopic is null ? "~" :
CurrentSubPath is null ? $"~/{CurrentTopic}" :
$"~/{CurrentTopic}/{CurrentSubPath}";
public ReplContext(NozApiClient api, LocalConfig config) { Api = api; Config = config; }
/// Notes filtered to the current topic (all notes at root).
public IEnumerable<NoteInfo> NotesInView()
{
if (CurrentTopic is null) return NoteCache;
var topicNotes = NoteCache.Where(n =>
string.Equals(n.Topic, CurrentTopic, StringComparison.OrdinalIgnoreCase));
if (CurrentSubPath is null) return topicNotes;
// Within a subfolder: filter by the slug sub-path relative to topic root
return topicNotes.Where(n =>
{
var rel = SlugRelative(n.Slug);
return rel.StartsWith(CurrentSubPath + "/") || rel == CurrentSubPath;
});
}
/// Slug of a note relative to the topic root folder.
/// "biosysteme/bio-regenerative-systeme/hydroponik" under topic "biosysteme"
/// → "bio-regenerative-systeme/hydroponik"
public string SlugRelative(string slug)
{
if (CurrentTopic is null) return slug;
var prefix = CurrentTopic + "/";
return slug.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? slug[prefix.Length..]
: slug;
}
/// Immediate sub-folders at the current navigation level.
/// At root: returns distinct topic names.
/// Within a topic: returns slug sub-segments one level below CurrentSubPath.
public IEnumerable<string> FoldersInView()
{
if (CurrentTopic is null)
return NoteCache
.Select(n => n.Topic)
.Where(t => !string.IsNullOrEmpty(t))
.Distinct(StringComparer.OrdinalIgnoreCase)!;
var depth = CurrentSubPath is null ? 0 : CurrentSubPath.Split('/').Length;
return NotesInView()
.Where(n => SlugRelative(n.Slug).Split('/').Length > depth + 1)
.Select(n => SlugRelative(n.Slug).Split('/').ElementAtOrDefault(depth))
.Where(s => s is not null)
.Distinct(StringComparer.OrdinalIgnoreCase)!;
}
/// Navigate into a topic or sub-folder segment.
public bool NavigateTo(string segment)
{
if (CurrentTopic is null)
{
// At root: segment must be a known topic
var topic = NoteCache
.Select(n => n.Topic)
.FirstOrDefault(t => string.Equals(t, segment, StringComparison.OrdinalIgnoreCase));
if (topic is null) return false;
CurrentTopic = topic;
CurrentSubPath = null;
return true;
}
// Within a topic: segment must be a valid slug sub-folder
var newSub = CurrentSubPath is null ? segment : $"{CurrentSubPath}/{segment}";
var hasNotes = NoteCache
.Where(n => string.Equals(n.Topic, CurrentTopic, StringComparison.OrdinalIgnoreCase))
.Any(n =>
{
var rel = SlugRelative(n.Slug);
return rel.StartsWith(newSub + "/") || rel == newSub;
});
if (!hasNotes) return false;
CurrentSubPath = newSub;
return true;
}
/// Navigate up one level.
public void NavigateUp()
{
if (CurrentSubPath is not null)
{
var parts = CurrentSubPath.Split('/');
CurrentSubPath = parts.Length > 1 ? string.Join('/', parts[..^1]) : null;
}
else
{
CurrentTopic = null;
}
}
/// Resolve a short name to a full slug for note commands (cat, edit, rm).
public string ResolveSlug(string nameOrSlug)
{
nameOrSlug = nameOrSlug.TrimEnd('/');
// Already a full known slug
if (NoteCache.Any(n => n.Slug == nameOrSlug)) return nameOrSlug;
if (CurrentTopic is null) return nameOrSlug;
// Try reconstructing the full slug from relative name
var topicPrefix = CurrentTopic + "/";
var sub = CurrentSubPath is null ? nameOrSlug : $"{CurrentSubPath}/{nameOrSlug}";
var full = topicPrefix + sub;
if (NoteCache.Any(n => n.Slug == full)) return full;
// Slug without topic prefix (note stored at root of noosphere)
if (NoteCache.Any(n => n.Slug == sub)) return sub;
return nameOrSlug;
}
/// Build a slug for a NEW note that doesn't exist yet.
public string ResolveNewSlug(string nameOrSlug)
{
nameOrSlug = nameOrSlug.TrimEnd('/');
if (nameOrSlug.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
nameOrSlug = nameOrSlug[..^3];
if (nameOrSlug.Contains('/')) return nameOrSlug;
if (CurrentTopic is null) return nameOrSlug;
var sub = CurrentSubPath is null ? nameOrSlug : $"{CurrentSubPath}/{nameOrSlug}";
return CurrentTopic + "/" + sub;
}
}